feat(agents): add Goose ACP agent support (#3313)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
committed by
GitHub
parent
76825c109e
commit
884418b4c9
@@ -0,0 +1,220 @@
|
||||
//nolint:dupl,goconst // Native-binary ACP agents (Kiro, Qoder, Hermes, Devin, ...) follow the same minimal scaffold; differences are the binary name, argv, and auth surface. Shared literals live in every peer file by convention.
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
_ "embed"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/usage"
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
//go:embed logos/goose_light.svg
|
||||
var gooseLogoLight []byte
|
||||
|
||||
//go:embed logos/goose_dark.svg
|
||||
var gooseLogoDark []byte
|
||||
|
||||
const gooseBin = "goose"
|
||||
|
||||
const gooseACPHelpMarker = "run goose as an acp agent"
|
||||
|
||||
var (
|
||||
_ Agent = (*GooseACP)(nil)
|
||||
_ PassthroughAgent = (*GooseACP)(nil)
|
||||
_ InferenceAgent = (*GooseACP)(nil)
|
||||
_ LoginAgent = (*GooseACP)(nil)
|
||||
)
|
||||
|
||||
// GooseACP implements Agent for the Goose coding agent (Agentic AI
|
||||
// Foundation / AAIF, formerly Block) using native ACP over stdin/stdout
|
||||
// (`goose acp`). Goose is installed as a standalone native binary
|
||||
// (`download_cli.sh`, Homebrew `block-goose-cli`, or `pip install goose-ai`),
|
||||
// so the launch command is the bare `goose` binary discovered on PATH.
|
||||
//
|
||||
// Auth is declarative: Goose reads its provider + credential config from
|
||||
// ~/.config/goose (not environment variables), so `goose configure` is the
|
||||
// interactive setup, and the primary remote-auth path is a config-file copy.
|
||||
type GooseACP struct {
|
||||
StandardPassthrough
|
||||
}
|
||||
|
||||
func NewGooseACP() *GooseACP {
|
||||
return &GooseACP{
|
||||
StandardPassthrough: StandardPassthrough{
|
||||
PermSettings: emptyPermSettings,
|
||||
Cfg: PassthroughConfig{
|
||||
Supported: true,
|
||||
Label: "CLI Passthrough",
|
||||
Description: "Show terminal directly instead of chat interface",
|
||||
PassthroughCmd: NewCommand(gooseBin),
|
||||
IdleTimeout: 3 * time.Second,
|
||||
BufferMaxBytes: DefaultBufferMaxBytes,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *GooseACP) ID() string { return "goose-acp" }
|
||||
func (a *GooseACP) Name() string { return "Goose ACP Agent" }
|
||||
func (a *GooseACP) DisplayName() string { return "Goose" }
|
||||
func (a *GooseACP) Description() string {
|
||||
return "AAIF Goose coding agent using the ACP protocol via goose acp. Local, extensible, open source."
|
||||
}
|
||||
func (a *GooseACP) Enabled() bool { return true }
|
||||
func (a *GooseACP) DisplayOrder() int { return 22 }
|
||||
|
||||
func (a *GooseACP) Logo(v LogoVariant) []byte {
|
||||
if v == LogoDark {
|
||||
return gooseLogoDark
|
||||
}
|
||||
return gooseLogoLight
|
||||
}
|
||||
|
||||
func (a *GooseACP) IsInstalled(ctx context.Context) (*DiscoveryResult, error) {
|
||||
// `goose acp` is a blocking server, so check its help surface instead of
|
||||
// starting the server or opening provider configuration. The subcommand
|
||||
// check also rejects unrelated binaries that happen to be named `goose`.
|
||||
result, err := Detect(ctx, detectGoose)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
result.SupportsMCP = true
|
||||
result.Capabilities = DiscoveryCapabilities{
|
||||
SupportsSessionResume: true,
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func detectGoose(ctx context.Context) (bool, string, error) {
|
||||
path, err := exec.LookPath(gooseBin)
|
||||
if err != nil {
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
checkCtx, cancel := context.WithTimeout(ctx, commandCheckTimeout)
|
||||
defer cancel()
|
||||
output, err := exec.CommandContext(checkCtx, path, "acp", "--help").CombinedOutput()
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return false, "", ctx.Err()
|
||||
}
|
||||
return false, "", nil
|
||||
}
|
||||
|
||||
help := strings.ToLower(string(output))
|
||||
if !strings.Contains(help, gooseACPHelpMarker) {
|
||||
return false, "", nil
|
||||
}
|
||||
return true, path, nil
|
||||
}
|
||||
|
||||
func (a *GooseACP) BuildCommand(_ CommandOptions) Command {
|
||||
return Cmd(gooseBin, "acp").Build()
|
||||
}
|
||||
|
||||
func (a *GooseACP) Runtime() *RuntimeConfig {
|
||||
canRecover := true
|
||||
return &RuntimeConfig{
|
||||
Cmd: Cmd(gooseBin, "acp").Build(),
|
||||
WorkingDir: "{workspace}",
|
||||
Env: map[string]string{},
|
||||
ResourceLimits: DefaultResourceLimits,
|
||||
Protocol: agent.ProtocolACP,
|
||||
// Goose supports Stdio and HTTP (StreamableHttp) MCP servers but
|
||||
// rejects SSE ("migrate to streamable_http"); do not force SSE.
|
||||
// Goose authenticates via its ~/.config/goose config files (not
|
||||
// provider API-key env vars), so no StripEnv is declared.
|
||||
SessionConfig: SessionConfig{
|
||||
NativeSessionResume: true,
|
||||
CanRecover: &canRecover,
|
||||
SessionDirTemplate: "{home}/.local/share/goose",
|
||||
SessionDirTarget: "/root/.local/share/goose",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (a *GooseACP) RemoteAuth() *RemoteAuth {
|
||||
return &RemoteAuth{
|
||||
Methods: []RemoteAuthMethod{
|
||||
{
|
||||
Type: "files",
|
||||
Label: "Copy Goose config files",
|
||||
SourceFiles: map[string][]string{
|
||||
"darwin": {".config/goose/config.yaml", ".config/goose/extensions.yaml", ".config/goose/secrets.yaml"},
|
||||
"linux": {".config/goose/config.yaml", ".config/goose/extensions.yaml", ".config/goose/secrets.yaml"},
|
||||
},
|
||||
// Provider + model config only. Sessions, checkpoints, and
|
||||
// history under ~/.local/share/goose are deliberately
|
||||
// excluded; credentials stored in the OS keyring are not
|
||||
// represented by config files and must be set up remotely.
|
||||
TargetRelDir: ".config/goose",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// goose configure is the interactive provider/model setup wizard (OAuth
|
||||
// flows, API keys). Credentials land in ~/.config/goose.
|
||||
func (a *GooseACP) LoginCommand() *LoginCommand {
|
||||
return &LoginCommand{
|
||||
Cmd: []string{gooseBin, "configure"},
|
||||
Description: "Configure Goose model provider credentials.",
|
||||
}
|
||||
}
|
||||
|
||||
func (a *GooseACP) InstallScript() string {
|
||||
// Keep the installer in a temporary file so curl failures cannot be hidden
|
||||
// by a successful bash exit status. Install into the first writable absolute
|
||||
// directory already on PATH so the parent Kandev process can discover Goose
|
||||
// immediately after the install job finishes.
|
||||
return `set -eu
|
||||
tmp="$(mktemp)"
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh -o "$tmp"
|
||||
install_dir=""
|
||||
old_ifs="$IFS"
|
||||
IFS=:
|
||||
for dir in ${PATH:-}; do
|
||||
[ -n "$dir" ] || continue
|
||||
case "$dir" in
|
||||
/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
dir=${dir%/}
|
||||
[ -n "$dir" ] || dir=/
|
||||
if [ -d "$dir" ] && [ -w "$dir" ]; then
|
||||
install_dir="$dir"
|
||||
break
|
||||
fi
|
||||
done
|
||||
IFS="$old_ifs"
|
||||
if [ -z "$install_dir" ]; then
|
||||
echo "Error: no writable absolute directory is available on PATH for Goose" >&2
|
||||
exit 1
|
||||
fi
|
||||
GOOSE_BIN_DIR="$install_dir" CONFIGURE=false bash "$tmp"
|
||||
command -v goose >/dev/null 2>&1
|
||||
resolved="$(command -v goose)"
|
||||
if [ "$resolved" != "$install_dir/goose" ]; then
|
||||
echo "Error: Goose installed at $install_dir/goose but another goose is earlier on PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
"$install_dir/goose" acp --help >/dev/null 2>&1`
|
||||
}
|
||||
|
||||
func (a *GooseACP) PermissionSettings() map[string]PermissionSetting {
|
||||
return emptyPermSettings
|
||||
}
|
||||
|
||||
func (a *GooseACP) InferenceConfig() *InferenceConfig {
|
||||
return &InferenceConfig{
|
||||
Supported: true,
|
||||
Command: NewCommand(gooseBin, "acp"),
|
||||
}
|
||||
}
|
||||
|
||||
func (a *GooseACP) BillingType() usage.BillingType { return defaultBillingType() }
|
||||
@@ -0,0 +1,353 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kandev/kandev/internal/agent/usage"
|
||||
"github.com/kandev/kandev/pkg/agent"
|
||||
)
|
||||
|
||||
func TestGooseACP_IDAndDisplay(t *testing.T) {
|
||||
a := NewGooseACP()
|
||||
if got := a.ID(); got != "goose-acp" {
|
||||
t.Errorf("ID() = %q, want goose-acp", got)
|
||||
}
|
||||
if got := a.Name(); got != "Goose ACP Agent" {
|
||||
t.Errorf("Name() = %q, want Goose ACP Agent", got)
|
||||
}
|
||||
if got := a.DisplayName(); got != "Goose" {
|
||||
t.Errorf("DisplayName() = %q, want Goose", got)
|
||||
}
|
||||
if !a.Enabled() {
|
||||
t.Error("Enabled() = false, want true")
|
||||
}
|
||||
if got := a.DisplayOrder(); got != 22 {
|
||||
t.Errorf("DisplayOrder() = %d, want 22", got)
|
||||
}
|
||||
if got := a.Description(); !strings.Contains(got, "goose acp") {
|
||||
t.Errorf("Description should mention the ACP launch command, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooseACP_AllCommandSurfaces(t *testing.T) {
|
||||
a := NewGooseACP()
|
||||
want := []string{"goose", "acp"}
|
||||
|
||||
assertArgvEqual(t, "BuildCommand", a.BuildCommand(CommandOptions{}).Args(), want)
|
||||
|
||||
rt := a.Runtime()
|
||||
if rt == nil {
|
||||
t.Fatal("Runtime() returned nil")
|
||||
}
|
||||
if rt.Protocol != agent.ProtocolACP {
|
||||
t.Errorf("Runtime.Protocol = %q, want ACP", rt.Protocol)
|
||||
}
|
||||
assertArgvEqual(t, "Runtime.Cmd", rt.Cmd.Args(), want)
|
||||
|
||||
ic := a.InferenceConfig()
|
||||
if ic == nil || !ic.Supported {
|
||||
t.Fatalf("InferenceConfig() = %+v, want Supported=true", ic)
|
||||
}
|
||||
assertArgvEqual(t, "InferenceConfig.Command", ic.Command.Args(), want)
|
||||
|
||||
pa, ok := any(a).(PassthroughAgent)
|
||||
if !ok {
|
||||
t.Fatal("GooseACP must implement PassthroughAgent")
|
||||
}
|
||||
assertArgvEqual(t, "PassthroughCmd", pa.PassthroughConfig().PassthroughCmd.Args(), []string{"goose"})
|
||||
}
|
||||
|
||||
func TestGooseACP_DoesNotForceSSE(t *testing.T) {
|
||||
rt := NewGooseACP().Runtime()
|
||||
// Goose supports Stdio + HTTP (StreamableHttp) MCP but rejects SSE; forcing
|
||||
// SSE would send a server Goose refuses to consume.
|
||||
if rt.AssumeMcpSse {
|
||||
t.Error("AssumeMcpSse = true, want false (Goose rejects SSE MCP)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooseACP_EnvAndStripEnv(t *testing.T) {
|
||||
rt := NewGooseACP().Runtime()
|
||||
// Goose authenticates via ~/.config/goose config files, not provider
|
||||
// API-key env vars, so there is no behavior-changing env to set or strip.
|
||||
if len(rt.Env) != 0 {
|
||||
t.Errorf("Runtime Env = %#v, want empty (no skip/env knob exists; global extensions still load)", rt.Env)
|
||||
}
|
||||
if len(rt.StripEnv) != 0 {
|
||||
t.Errorf("Runtime StripEnv = %#v, want empty (Goose reads config files, not provider env vars)", rt.StripEnv)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooseACP_InstallScript(t *testing.T) {
|
||||
got := NewGooseACP().InstallScript()
|
||||
for _, needle := range []string{
|
||||
"set -eu",
|
||||
`tmp="$(mktemp)"`,
|
||||
`trap 'rm -f "$tmp"' EXIT`,
|
||||
"curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh",
|
||||
`-o "$tmp"`,
|
||||
"install_dir=",
|
||||
`GOOSE_BIN_DIR="$install_dir" CONFIGURE=false bash "$tmp"`,
|
||||
`command -v goose`,
|
||||
"goose",
|
||||
} {
|
||||
if !strings.Contains(got, needle) {
|
||||
t.Errorf("InstallScript missing %q: %q", needle, got)
|
||||
}
|
||||
}
|
||||
if strings.HasPrefix(got, "npm install -g ") {
|
||||
t.Errorf("InstallScript should use the native Goose installer, got npm script: %q", got)
|
||||
}
|
||||
if !strings.Contains(got, "CONFIGURE=false") {
|
||||
t.Errorf("InstallScript should set CONFIGURE=false for deterministic non-interactive install, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooseACPInstallScriptUsesFirstWritablePathDirectory(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("install script is POSIX shell")
|
||||
}
|
||||
|
||||
home := t.TempDir()
|
||||
binDir := filepath.Join(t.TempDir(), "bin")
|
||||
if err := os.MkdirAll(binDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir fake bin: %v", err)
|
||||
}
|
||||
|
||||
fakeCurl := `#!/bin/sh
|
||||
out=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = "-o" ]; then
|
||||
shift
|
||||
out="$1"
|
||||
fi
|
||||
shift
|
||||
done
|
||||
cat > "$out" <<'INSTALL'
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
mkdir -p "$GOOSE_BIN_DIR"
|
||||
cat > "$GOOSE_BIN_DIR/goose" <<'GOOSE'
|
||||
#!/bin/sh
|
||||
echo "Run goose as an ACP agent server on stdio"
|
||||
GOOSE
|
||||
chmod +x "$GOOSE_BIN_DIR/goose"
|
||||
INSTALL
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(binDir, "curl"), []byte(fakeCurl), 0o755); err != nil {
|
||||
t.Fatalf("write fake curl: %v", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("sh", "-c", NewGooseACP().InstallScript())
|
||||
cmd.Env = []string{
|
||||
"HOME=" + home,
|
||||
"PATH=" + binDir + ":/usr/bin:/bin",
|
||||
}
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("InstallScript failed: %v\n%s", err, out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(binDir, "goose")); err != nil {
|
||||
t.Fatalf("expected goose binary in first writable PATH directory: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooseACPInstallScriptPropagatesDownloadFailure(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("install script is POSIX shell")
|
||||
}
|
||||
|
||||
binDir := t.TempDir()
|
||||
fakeCurl := "#!/bin/sh\nexit 1\n"
|
||||
if err := os.WriteFile(filepath.Join(binDir, "curl"), []byte(fakeCurl), 0o755); err != nil {
|
||||
t.Fatalf("write fake curl: %v", err)
|
||||
}
|
||||
|
||||
cmd := exec.Command("sh", "-c", NewGooseACP().InstallScript())
|
||||
cmd.Env = []string{
|
||||
"HOME=" + t.TempDir(),
|
||||
"PATH=" + binDir + ":/usr/bin:/bin",
|
||||
}
|
||||
if out, err := cmd.CombinedOutput(); err == nil {
|
||||
t.Fatalf("InstallScript succeeded after curl failure; output: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooseACP_DetectionRequiresGlobalBinary(t *testing.T) {
|
||||
if _, err := exec.LookPath("goose"); err == nil {
|
||||
t.Skip("detection binary \"goose\" is on PATH; can't verify availability requirement")
|
||||
}
|
||||
result, err := NewGooseACP().IsInstalled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("IsInstalled error: %v", err)
|
||||
}
|
||||
if result.Available {
|
||||
t.Error("Available=true without goose on PATH; discovery must not imply install")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooseACP_DetectionRejectsUnrelatedGooseBinary(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell executable fixture is Unix-specific")
|
||||
}
|
||||
|
||||
binDir := t.TempDir()
|
||||
goosePath := filepath.Join(binDir, gooseBin)
|
||||
// Pressly's database-migration CLI also installs a `goose` binary. It may
|
||||
// answer generic version requests successfully, but it has no Goose ACP
|
||||
// help surface.
|
||||
script := "#!/bin/sh\necho 'goose version v3.24.3'\n"
|
||||
if err := os.WriteFile(goosePath, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write unrelated goose executable: %v", err)
|
||||
}
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
result, err := NewGooseACP().IsInstalled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("IsInstalled error: %v", err)
|
||||
}
|
||||
if result.Available {
|
||||
t.Fatal("Available=true for unrelated goose binary without ACP help")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooseACP_DetectionAcceptsACPHelp(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("shell executable fixture is Unix-specific")
|
||||
}
|
||||
|
||||
binDir := t.TempDir()
|
||||
goosePath := filepath.Join(binDir, gooseBin)
|
||||
script := "#!/bin/sh\necho 'Run goose as an ACP agent server on stdio'\n"
|
||||
if err := os.WriteFile(goosePath, []byte(script), 0o755); err != nil {
|
||||
t.Fatalf("write Goose executable: %v", err)
|
||||
}
|
||||
t.Setenv("PATH", binDir)
|
||||
|
||||
result, err := NewGooseACP().IsInstalled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("IsInstalled error: %v", err)
|
||||
}
|
||||
if !result.Available {
|
||||
t.Fatal("Available=false for Goose binary with ACP help")
|
||||
}
|
||||
if result.MatchedPath != goosePath {
|
||||
t.Errorf("MatchedPath = %q, want %q", result.MatchedPath, goosePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooseACP_SupportsMCPAndResume(t *testing.T) {
|
||||
if _, err := exec.LookPath("goose"); err != nil {
|
||||
t.Skip("goose not on PATH; MCP/resume capabilities surface only when detected")
|
||||
}
|
||||
result, err := NewGooseACP().IsInstalled(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("IsInstalled error: %v", err)
|
||||
}
|
||||
if !result.SupportsMCP {
|
||||
t.Error("SupportsMCP = false, want true (session/new mcpServers)")
|
||||
}
|
||||
if !result.Capabilities.SupportsSessionResume {
|
||||
t.Error("SupportsSessionResume = false, want true (session/load)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooseACP_LogosNonEmpty(t *testing.T) {
|
||||
a := NewGooseACP()
|
||||
if len(a.Logo(LogoLight)) == 0 {
|
||||
t.Error("Logo(LogoLight) is empty")
|
||||
}
|
||||
if len(a.Logo(LogoDark)) == 0 {
|
||||
t.Error("Logo(LogoDark) is empty")
|
||||
}
|
||||
if !strings.Contains(string(a.Logo(LogoLight)), "<svg") {
|
||||
t.Error("Logo(LogoLight) is not SVG")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooseACP_RemoteAuth(t *testing.T) {
|
||||
auth := NewGooseACP().RemoteAuth()
|
||||
if auth == nil {
|
||||
t.Fatal("RemoteAuth() returned nil")
|
||||
}
|
||||
if len(auth.Methods) != 1 {
|
||||
t.Fatalf("Methods len = %d, want 1", len(auth.Methods))
|
||||
}
|
||||
|
||||
files := auth.Methods[0]
|
||||
if files.Type != "files" {
|
||||
t.Errorf("Methods[0].Type = %q, want files", files.Type)
|
||||
}
|
||||
if files.TargetRelDir != ".config/goose" {
|
||||
t.Errorf("TargetRelDir = %q, want .config/goose", files.TargetRelDir)
|
||||
}
|
||||
for _, osName := range []string{"darwin", "linux"} {
|
||||
paths := files.SourceFiles[osName]
|
||||
want := []string{".config/goose/config.yaml", ".config/goose/extensions.yaml", ".config/goose/secrets.yaml"}
|
||||
if !slices.Equal(paths, want) {
|
||||
t.Errorf("SourceFiles[%s] = %#v, want %#v", osName, paths, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooseACP_LoginCommand(t *testing.T) {
|
||||
cmd := NewGooseACP().LoginCommand()
|
||||
if cmd == nil {
|
||||
t.Fatal("LoginCommand() returned nil")
|
||||
}
|
||||
want := []string{"goose", "configure"}
|
||||
if !slices.Equal(cmd.Cmd, want) {
|
||||
t.Errorf("LoginCommand.Cmd = %#v, want %#v", cmd.Cmd, want)
|
||||
}
|
||||
if cmd.Description == "" {
|
||||
t.Error("LoginCommand.Description is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooseACP_SessionConfig(t *testing.T) {
|
||||
rt := NewGooseACP().Runtime()
|
||||
if rt.WorkingDir != "{workspace}" {
|
||||
t.Errorf("WorkingDir = %q, want {workspace}", rt.WorkingDir)
|
||||
}
|
||||
sc := rt.SessionConfig
|
||||
if !sc.NativeSessionResume {
|
||||
t.Error("NativeSessionResume = false, want true")
|
||||
}
|
||||
if sc.NewSessionOnWorkspaceRebind {
|
||||
t.Error("NewSessionOnWorkspaceRebind = true, want false")
|
||||
}
|
||||
if sc.CanRecover == nil || !*sc.CanRecover {
|
||||
t.Error("CanRecover must be true")
|
||||
}
|
||||
if sc.SessionDirTemplate != "{home}/.local/share/goose" {
|
||||
t.Errorf("SessionDirTemplate = %q, want {home}/.local/share/goose", sc.SessionDirTemplate)
|
||||
}
|
||||
if sc.SessionDirTarget != "/root/.local/share/goose" {
|
||||
t.Errorf("SessionDirTarget = %q, want /root/.local/share/goose", sc.SessionDirTarget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGooseACP_PermissionAndBillingDefaults(t *testing.T) {
|
||||
a := NewGooseACP()
|
||||
if len(a.PermissionSettings()) != 0 {
|
||||
t.Errorf("PermissionSettings() = %#v, want empty (agentctl auto-approve is authoritative)", a.PermissionSettings())
|
||||
}
|
||||
if got := a.BillingType(); got != usage.BillingTypeAPIKey {
|
||||
t.Errorf("BillingType() = %q, want %q", got, usage.BillingTypeAPIKey)
|
||||
}
|
||||
catalog := CatalogPermissionSettings(a)
|
||||
auto, ok := catalog[PermissionKeyAutoApprove]
|
||||
if !ok {
|
||||
t.Fatal("catalog missing auto_approve")
|
||||
}
|
||||
if auto.ApplyMethod != PermissionApplyMethodAgentctlAutoApprove {
|
||||
t.Errorf("auto_approve ApplyMethod = %q, want agentctl auto-approve", auto.ApplyMethod)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg fill="#FFFFFF" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Goose</title><path d="M21.595 23.61c1.167-.254 2.405-.944 2.405-.944l-2.167-1.784a12.124 12.124 0 01-2.695-3.131 12.127 12.127 0 00-3.97-4.049l-.794-.462a1.115 1.115 0 01-.488-.815.844.844 0 01.154-.575c.413-.582 2.548-3.115 2.94-3.44.503-.416 1.065-.762 1.586-1.159.074-.056.148-.112.221-.17.003-.002.007-.004.009-.007.167-.131.325-.272.45-.438.453-.524.563-.988.59-1.193-.061-.197-.244-.639-.753-1.148.319.02.705.272 1.056.569.235-.376.481-.773.727-1.171.165-.266-.08-.465-.086-.471h-.001V3.22c-.007-.007-.206-.25-.471-.086-.567.35-1.134.702-1.639 1.021 0 0-.597-.012-1.305.599a2.464 2.464 0 00-.438.45l-.007.009c-.058.072-.114.147-.17.221-.397.521-.743 1.083-1.16 1.587-.323.391-2.857 2.526-3.44 2.94a.842.842 0 01-.574.153 1.115 1.115 0 01-.815-.488l-.462-.794a12.123 12.123 0 00-4.049-3.97 12.133 12.133 0 01-3.13-2.695L1.332 0S.643 1.238.39 2.405c.352.428 1.27 1.49 2.34 2.302C1.58 4.167.73 3.75.06 3.4c-.103.765-.063 1.92.043 2.816.726.317 1.961.806 3.219 1.066-1.006.236-2.11.278-2.961.262.15.554.358 1.119.64 1.688.119.263.25.52.39.77.452.125 2.222.383 3.164.171l-2.51.897a27.776 27.776 0 002.544 2.726c2.031-1.092 2.494-1.241 4.018-2.238-2.467 2.008-3.108 2.828-3.8 3.67l-.483.678c-.25.351-.469.725-.65 1.117-.61 1.31-1.47 4.1-1.47 4.1-.154.486.202.842.674.674 0 0 2.79-.861 4.1-1.47.392-.182.766-.4 1.118-.65l.677-.483c.227-.187.453-.37.701-.586 0 0 1.705 2.02 3.458 3.349l.896-2.511c-.211.942.046 2.712.17 3.163.252.142.509.272.772.392.569.28 1.134.49 1.688.64-.016-.853.026-1.956.261-2.962.26 1.258.75 2.493 1.067 3.219.895.106 2.051.146 2.816.043a73.87 73.87 0 01-1.308-2.67c.811 1.07 1.874 1.988 2.302 2.34h-.001z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="#000000" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Goose</title><path d="M21.595 23.61c1.167-.254 2.405-.944 2.405-.944l-2.167-1.784a12.124 12.124 0 01-2.695-3.131 12.127 12.127 0 00-3.97-4.049l-.794-.462a1.115 1.115 0 01-.488-.815.844.844 0 01.154-.575c.413-.582 2.548-3.115 2.94-3.44.503-.416 1.065-.762 1.586-1.159.074-.056.148-.112.221-.17.003-.002.007-.004.009-.007.167-.131.325-.272.45-.438.453-.524.563-.988.59-1.193-.061-.197-.244-.639-.753-1.148.319.02.705.272 1.056.569.235-.376.481-.773.727-1.171.165-.266-.08-.465-.086-.471h-.001V3.22c-.007-.007-.206-.25-.471-.086-.567.35-1.134.702-1.639 1.021 0 0-.597-.012-1.305.599a2.464 2.464 0 00-.438.45l-.007.009c-.058.072-.114.147-.17.221-.397.521-.743 1.083-1.16 1.587-.323.391-2.857 2.526-3.44 2.94a.842.842 0 01-.574.153 1.115 1.115 0 01-.815-.488l-.462-.794a12.123 12.123 0 00-4.049-3.97 12.133 12.133 0 01-3.13-2.695L1.332 0S.643 1.238.39 2.405c.352.428 1.27 1.49 2.34 2.302C1.58 4.167.73 3.75.06 3.4c-.103.765-.063 1.92.043 2.816.726.317 1.961.806 3.219 1.066-1.006.236-2.11.278-2.961.262.15.554.358 1.119.64 1.688.119.263.25.52.39.77.452.125 2.222.383 3.164.171l-2.51.897a27.776 27.776 0 002.544 2.726c2.031-1.092 2.494-1.241 4.018-2.238-2.467 2.008-3.108 2.828-3.8 3.67l-.483.678c-.25.351-.469.725-.65 1.117-.61 1.31-1.47 4.1-1.47 4.1-.154.486.202.842.674.674 0 0 2.79-.861 4.1-1.47.392-.182.766-.4 1.118-.65l.677-.483c.227-.187.453-.37.701-.586 0 0 1.705 2.02 3.458 3.349l.896-2.511c-.211.942.046 2.712.17 3.163.252.142.509.272.772.392.569.28 1.134.49 1.688.64-.016-.853.026-1.956.261-2.962.26 1.258.75 2.493 1.067 3.219.895.106 2.051.146 2.816.043a73.87 73.87 0 01-1.308-2.67c.811 1.07 1.874 1.988 2.302 2.34h-.001z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.8 KiB |
@@ -162,6 +162,14 @@ grep -qxF 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.bashrc" 2>/dev/null ||
|
||||
installViaNpm: false,
|
||||
sessionDirTemplate: "{home}/.hermes",
|
||||
}},
|
||||
{func() Agent { return NewGooseACP() }, acpAgentSpec{
|
||||
id: "goose-acp", displayName: "Goose", detectBinaries: []string{"goose"},
|
||||
expectedArgv: []string{"goose", "acp"},
|
||||
inferenceArgv: []string{"goose", "acp"},
|
||||
passthroughArgv: []string{"goose"},
|
||||
installViaNpm: false,
|
||||
sessionDirTemplate: "{home}/.local/share/goose",
|
||||
}},
|
||||
}
|
||||
|
||||
func TestNewACPAgents_IDAndDisplay(t *testing.T) {
|
||||
|
||||
@@ -86,6 +86,7 @@ func (r *Registry) LoadDefaults() {
|
||||
agents.NewDevinACP(),
|
||||
agents.NewGrokACP(),
|
||||
agents.NewHermesACP(),
|
||||
agents.NewGooseACP(),
|
||||
agents.NewAntigravityACP(),
|
||||
agents.NewMockAgent(),
|
||||
}
|
||||
|
||||
@@ -1188,6 +1188,7 @@ var allowedProbeCommands = map[string]string{
|
||||
"auggie": "auggie",
|
||||
"cursor-agent": "cursor-agent",
|
||||
"devin": "devin",
|
||||
"goose": "goose",
|
||||
"grok": "grok",
|
||||
"hermes": "hermes",
|
||||
"kimi": "kimi",
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
created: 2026-09-03
|
||||
status: in-progress
|
||||
referenced_runbook: docs/public/add-agent-cli.md
|
||||
---
|
||||
|
||||
# Implementation Plan: Goose (AAIF) ACP Agent Support
|
||||
|
||||
## Overview
|
||||
|
||||
Add the Goose open-source coding agent (Agentic AI Foundation / AAIF, formerly
|
||||
Block; repo `aaif-goose/goose`) as a first-class Kandev agent. Users can select
|
||||
Goose as their coding agent; Kandev detects it, launches it over ACP, wires
|
||||
permissions and MCP through `session/new`, supports one-shot inference, CLI
|
||||
passthrough, and session resume.
|
||||
|
||||
Goose speaks **ACP over stdio** via `goose acp`, the exact protocol Kandev's
|
||||
agentctl adapter factory accepts. It is distributed as a **native CLI binary**
|
||||
(install script `download_cli.sh`, Homebrew `block-goose-cli`, or `pip install
|
||||
goose-ai`), so it is discovered purely on PATH and uses **no** managed
|
||||
npm runtime. Config lives under `~/.config/goose`; session/history data under
|
||||
`~/.local/share/goose`. Credentials come from `goose configure`.
|
||||
|
||||
Because Goose is a native-binary ACP agent, this is the runbook's "Ship a
|
||||
structured ACP integration" path (`docs/public/add-agent-cli.md`), modeled on
|
||||
`agents/gemini.go` (example) and `agents/agent.go` (contract); Hermes, Qwen,
|
||||
and Devin are the closest siblings.
|
||||
|
||||
## Verified integration contract
|
||||
|
||||
Evidence is drawn from the Goose source, an ACP client reference
|
||||
(`catatafishen/agentbridge#1009`, merged), terminal-transcript detection
|
||||
(`herdrdev/herdr#3279`), and Paseo's per-agent catalog for positioning.
|
||||
|
||||
- **Launch:** `["goose", "acp"]`; passthrough `["goose"]`.
|
||||
- **Protocol:** `agent.ProtocolACP` (only protocol the adapter factory accepts).
|
||||
- **MCP:** delivered natively via ACP `session/new` (`mcpServers`); **no**
|
||||
`ProjectMCPStrategy` needed. Goose supports Stdio and HTTP (StreamableHttp)
|
||||
MCP but **rejects SSE**; keep `AssumeMcpSse=false`.
|
||||
- **Developer built-in:** `goose acp` auto-enables Goose's `developer` built-in
|
||||
extension by default; this is expected and documented.
|
||||
- **Global extensions:** Goose loads globally-enabled extensions even when
|
||||
`session/new` MCP servers are passed; there is no skip knob on `goose acp`.
|
||||
Accepted behavior; a slow unrelated global extension may delay `initialize`.
|
||||
- **Session resume:** ACP `session/load`; native resume enabled with session
|
||||
root `{home}/.local/share/goose`.
|
||||
- **Auth:** declarative config (`~/.config/goose`); primary remote path is a
|
||||
`files` `RemoteAuth` copy of the config dir; optional `goose configure` PTY
|
||||
login.
|
||||
- **Permissions:** Goose presents real interactive approval prompts; map
|
||||
`PermissionKeyAutoApprove` via `PermissionApplyMethodAgentctlAutoApprove`.
|
||||
- **Environment:** no required environment variables; Goose reads provider
|
||||
credentials from its config files.
|
||||
- **Install script:** Kandev downloads the official script to a temporary file,
|
||||
runs it with `CONFIGURE=false` in the first writable absolute directory on
|
||||
the current `PATH`, and verifies the installed `goose acp --help` command.
|
||||
|
||||
> **Deviation from earlier plan draft:** `NativeBinaryAgent` is **not**
|
||||
> implemented. The interface (`agent.go`) is specifically for npm-distributed
|
||||
> packages that also ship a standalone CLI (e.g. Copilot) to prefer over
|
||||
> `npx`. Goose is a pure native binary with no npm launch path, so the
|
||||
> interface does not apply; implementing it would be a meaningless flag.
|
||||
|
||||
## Files
|
||||
|
||||
### Create
|
||||
|
||||
- `apps/backend/internal/agent/agents/goose_acp.go`
|
||||
- `apps/backend/internal/agent/agents/goose_acp_test.go`
|
||||
- `apps/backend/internal/agent/agents/logos/goose_light.svg`
|
||||
- `apps/backend/internal/agent/agents/logos/goose_dark.svg`
|
||||
- `docs/plans/goose-agent/plan.md` (this document; shipped in the PR as a
|
||||
reference for reviewers and future contributors)
|
||||
|
||||
### Modify
|
||||
|
||||
- `apps/backend/internal/agent/registry/registry.go` — add
|
||||
`agents.NewGooseACP()` to `LoadDefaults()` after `NewHermesACP()`.
|
||||
- `apps/backend/internal/agent/agents/new_acp_agents_test.go` — add a `GooseACP`
|
||||
entry to `newACPAgentSpecs` so the shared contract matrix, detection, logos,
|
||||
and display-order tests cover it.
|
||||
|
||||
### Docs
|
||||
|
||||
- `docs/public/agents-and-profiles.md` — document Goose as a supported agent
|
||||
(install, launch via `goose acp`, credentials via `goose configure`) at parity
|
||||
with peers.
|
||||
|
||||
## Steps
|
||||
|
||||
1. **Contract pinning (TDD)** — write `goose_acp_test.go` first, pinning:
|
||||
identity (`goose-acp`, "Goose ACP Agent", "Goose", DisplayOrder 22);
|
||||
`["goose","acp"]` across BuildCommand/Runtime.Cmd/InferenceConfig.Command;
|
||||
passthrough `["goose"]`; `Runtime().Protocol == agent.ProtocolACP`;
|
||||
`SessionDirTemplate == "{home}/.local/share/goose"`; InstallScript; RemoteAuth
|
||||
files for darwin/linux; no provider environment requirements; logos non-empty;
|
||||
bounded ACP-help detection.
|
||||
2. **Implement `goose_acp.go`** — `type GooseACP struct { StandardPassthrough }`;
|
||||
marker interfaces `Agent`, `PassthroughAgent`, `InferenceAgent`,
|
||||
`LoginAgent`; `goose` binary via `Cmd`/`Command` builders (no shell strings);
|
||||
`IsInstalled` via a bounded `goose acp --help` identity check; `Runtime()` with
|
||||
Protocol ACP, native resume, pinned session dir, `AssumeMcpSse=false`,
|
||||
no `StripEnv`; `RemoteAuth` file copy; `InstallScript`; `PermissionSettings`;
|
||||
`InferenceConfig`.
|
||||
3. **Register** in `registry.go` `LoadDefaults()`.
|
||||
4. **Test wiring** — add Goose to `new_acp_agents_test.go` matrix; add SVGs.
|
||||
5. **Validate** — `make -C apps/backend` package tests (agents, registry,
|
||||
lifecycle, adapter), `make lint-backend`, `make test-backend`, and the
|
||||
real-adapter `make -C apps/backend test-e2e` (gated, may need a real goose
|
||||
install / login).
|
||||
6. **Manual smoke** — `make dev`, confirm Goose appears in the agent/profile
|
||||
picker, start/prompt/cancel/resume, model/mode refresh, permissions,
|
||||
`session/new` MCP registration via `acpdbg mcp-probe goose`.
|
||||
7. **Docs + i18n** — update public docs; any user-facing copy via `t()` / i18n
|
||||
ratchets (agent name/description surface via the backend DTO, English).
|
||||
8. **Commit** — single `feat(agents): add Goose ACP agent support` with this plan
|
||||
doc included.
|
||||
|
||||
## Risks
|
||||
|
||||
- **ACP wire compatibility:** `goose acp` is documented ACP-compatible and its
|
||||
stdio entry is stable (low impact). A bridge shim is the fallback if a
|
||||
mismatch surfaces; verify with a real spawn early.
|
||||
- **Tool-title humanization (from #1009):** Goose humanizes MCP `tool_call`
|
||||
titles (`"extension: read file · /arg"`); confirm Kandev's stream layer
|
||||
handles or normalizes these, else add a small parser.
|
||||
- **Global extensions auto-load:** accepted; may delay `initialize` if a slow
|
||||
unrelated extension is configured.
|
||||
- **Detection:** `goose acp` is a blocking server; use its bounded `--help`
|
||||
command, not the server itself or a provider-opening probe.
|
||||
- **Windows support** is unverified upstream; treated as a caveat.
|
||||
@@ -22,10 +22,12 @@ Open **Settings > Agents** (`/settings/agents`). Kandev scans the host on which
|
||||
|
||||

|
||||
|
||||
The production registry currently shows Auggie, Claude, Codex, Copilot, Gemini, OpenCode, Amp, Qwen, iFlow (beta), Droid, Kilocode, Pi, Cursor, Kimi, Kiro, Qoder, Trae, `omp`, Devin, Grok, Hermes, and Antigravity. An entry is usable only when its executable is supported on the current platform and available to the Kandev process. Development and E2E profiles can add mock agents that are not product integrations.
|
||||
The production registry currently shows Auggie, Claude, Codex, Copilot, Gemini, OpenCode, Amp, Qwen, iFlow (beta), Droid, Kilocode, Pi, Cursor, Kimi, Kiro, Qoder, Trae, `omp`, Devin, Grok, Hermes, Goose, and Antigravity. An entry is usable only when its executable is supported on the current platform and available to the Kandev process. Development and E2E profiles can add mock agents that are not product integrations.
|
||||
|
||||
Hermes launches with `hermes acp`. Install the required `hermes` executable from its **Settings > Agents** card, which runs the official Hermes installer. Hermes currently supports task and workspace sessions. Office-assigned skill injection is not yet supported.
|
||||
|
||||
Goose launches with `goose acp`. Its **Settings > Agents** card runs only the official `download_cli.sh` installer. Homebrew (`block-goose-cli`) and pip (`pip install goose-ai`) are manual alternatives. Configure your model provider with `goose configure`. Goose currently supports task and workspace sessions.
|
||||
|
||||
Antigravity has no automated install: Google distributes `agy_acp_server.par` (`agy_acp_server.exe` on Windows) and its `localharness_external` or `localharness` sibling (`localharness_external.exe` or `localharness.exe` on Windows) as a signed archive through the [ACP registry](https://github.com/agentclientprotocol/registry/tree/main/antigravity-acp) rather than npm, so extract both files into one directory and add it to PATH yourself. Kandev fails discovery closed when the harness sibling is missing or not executable, so a partial extraction reports as not installed rather than as a broken session.
|
||||
|
||||
### Pi command surfaces
|
||||
|
||||
Reference in New Issue
Block a user