Skip to content
Merged
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
108 changes: 95 additions & 13 deletions internal/agents/detect.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,28 @@ import (
"fmt"
"os"
"regexp"
"strings"
)

// AgentName is a validated agent identifier safe for use in HTTP headers.
type AgentName string

const (
agentAmp AgentName = "amp"
agentClaudeCode AgentName = "claude-code"
agentCodex AgentName = "codex"
agentCopilotCLI AgentName = "copilot-cli"
agentGeminiCLI AgentName = "gemini-cli"
agentOpencode AgentName = "opencode"
agentAmp AgentName = "amp"
agentClaudeCode AgentName = "claude-code"
agentCodex AgentName = "codex"
agentCopilotCLI AgentName = "copilot-cli"
agentGeminiCLI AgentName = "gemini-cli"
agentOpencode AgentName = "opencode"
agentAntigravity AgentName = "antigravity"
agentAugmentCLI AgentName = "augment-cli"
agentReplit AgentName = "replit"
agentGoose AgentName = "goose"
agentCowork AgentName = "cowork"
agentCursor AgentName = "cursor"
agentCursorCLI AgentName = "cursor-cli"
agentKiro AgentName = "kiro"
agentPi AgentName = "pi"
)

var validAgentName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
Expand Down Expand Up @@ -46,7 +56,7 @@ func detectWith(lookup func(string) (string, bool)) AgentName {
return v
}

// Generic agent identifiers checked first because they are the most specific signal.
// Generic agent identifiers - checked first because they are the most specific signal.
if v, ok := lookup("AI_AGENT"); ok && v != "" {
if name, err := parseAgentName(v); err == nil {
return name
Expand All @@ -60,15 +70,15 @@ func detectWith(lookup func(string) (string, bool)) AgentName {
return agentAmp
}

// OpenAI Codex CLI https://github.com/openai/codex
// OpenAI Codex CLI - https://github.com/openai/codex
// CODEX_SANDBOX: https://github.com/openai/codex/blob/95e1d5993985019ce0ce0d10689caf1375f95120/codex-rs/core/src/spawn.rs#L25
// CODEX_THREAD_ID: https://github.com/openai/codex/blob/95e1d5993985019ce0ce0d10689caf1375f95120/codex-rs/core/src/exec_env.rs#L8
// CODEX_CI: https://github.com/openai/codex/blob/95e1d5993985019ce0ce0d10689caf1375f95120/codex-rs/core/src/unified_exec/process_manager.rs#L64
if isSet("CODEX_SANDBOX") || isSet("CODEX_CI") || isSet("CODEX_THREAD_ID") {
return agentCodex
}

// Google Gemini CLI https://github.com/google-gemini/gemini-cli
// Google Gemini CLI - https://github.com/google-gemini/gemini-cli
// GEMINI_CLI: https://github.com/google-gemini/gemini-cli/blob/46fd7b4864111032a1c7dfa1821b2000fc7531da/docs/tools/shell.md#L96-L97
if isSet("GEMINI_CLI") {
return agentGeminiCLI
Expand All @@ -80,20 +90,92 @@ func detectWith(lookup func(string) (string, bool)) AgentName {
return agentCopilotCLI
}

// OpenCode https://github.com/anomalyco/opencode
// OpenCode - https://github.com/anomalyco/opencode
// OPENCODE: https://github.com/anomalyco/opencode/blob/fde201c286a83ff32dda9b41d61d734a4449fe70/packages/opencode/src/index.ts#L78-L80
// Not OPENCODE_CALLER or OPENCODE_CLIENT: they name the client that launched
// opencode (e.g. the VS Code extension), not the running agent.
if isSet("OPENCODE") {
return agentOpencode
}

// Anthropic Claude Code — https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview
// Antigravity
// No first-party docs
if isSet("ANTIGRAVITY_AGENT") {
return agentAntigravity
}

// Augment CLI
// No first-party docs
if isSet("AUGMENT_AGENT") {
return agentAugmentCLI
}

// Replit
// REPL_ID is present throughout any Replit environment, not only when a
// Replit agent is driving the CLI, so it is a broad, low-confidence signal.
// REPL_ID: https://github.com/replit/go-replidentity/blob/2966ea2d227d572f6054ee8f077ad16a1be02663/examples/extract.go#L25
if isSet("REPL_ID") {
return agentReplit
}

// Anthropic Claude Code - https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview
// CLAUDECODE: https://code.claude.com/docs/en/env-vars (CLAUDECODE section)
// Checked last because other agents (e.g. Amp) set CLAUDECODE=1 alongside their own vars.
if isSet("CLAUDECODE") {
// CLAUDE_CODE, CLAUDE_CODE_IS_COWORK: no first-party docs
//
// Cowork is a Claude Code mode that also sets CLAUDECODE, so it is checked
// first to win over the generic Claude Code signal below.
if isSet("CLAUDE_CODE_IS_COWORK") {
return agentCowork
}

// Claude Code is checked after Amp and Cowork, which also set CLAUDECODE, so
// those more specific agents are detected first.
if isSet("CLAUDECODE") || isSet("CLAUDE_CODE") {
// There is a CLAUDE_CODE_ENTRYPOINT env var that is set to `cli` or `desktop` etc, but it's not documented
// so we don't want to rely on it too heavily. We'll just return a generic claude-code agent name.
return agentClaudeCode
}

// Cursor
// No first-party docs
// CURSOR_TRACE_ID (IDE) takes precedence over the Cursor CLI signal below.
if isSet("CURSOR_TRACE_ID") {
return agentCursor
}

// Cursor CLI
// No first-party docs
if isSet("CURSOR_AGENT") || valueOf("CURSOR_EXTENSION_HOST_ROLE") == "agent-exec" {
return agentCursorCLI
}

// Single-source signals matched against one environment variable. These
// carry lower corroboration than the presence-based agents above, so they
// are checked after them.

// Kiro
// No first-party docs
if valueOf("TERM_PROGRAM") == "kiro" {
return agentKiro
}

// Pi
// No first-party docs
// Anchored to a path separator so it only matches ".pi/agent" as a real
// path segment, not an incidental substring. The Windows separator is
// matched too, though confidence there is lower since it is unconfirmed
// that pi uses this layout on Windows.
if strings.Contains(valueOf("PATH"), "/.pi/agent") || strings.Contains(valueOf("PATH"), `\.pi\agent`) {
return agentPi
}

// Goose is checked last because GOOSE_PROVIDER only indicates that Goose is
// configured as a model provider, not that it is driving the CLI, so any
// more specific signal above should win.
// GOOSE_PROVIDER: https://github.com/aaif-goose/goose/blob/48a2a3d1804ae75eb7b208a5d0d73fd976511b80/crates/goose/src/config/providers.rs#L93
if isSet("GOOSE_PROVIDER") {
return agentGoose
}

return ""
}
95 changes: 95 additions & 0 deletions internal/agents/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,101 @@ func TestDetectWith(t *testing.T) {
env: map[string]string{"AI_AGENT": "bad agent", "GEMINI_CLI": "1"},
wantAgent: "gemini-cli",
},
{
name: "ANTIGRAVITY_AGENT",
env: map[string]string{"ANTIGRAVITY_AGENT": "1"},
wantAgent: "antigravity",
},
{
name: "AUGMENT_AGENT",
env: map[string]string{"AUGMENT_AGENT": "1"},
wantAgent: "augment-cli",
},
{
name: "REPL_ID",
env: map[string]string{"REPL_ID": "abc123"},
wantAgent: "replit",
},
{
name: "GOOSE_PROVIDER",
env: map[string]string{"GOOSE_PROVIDER": "anthropic"},
wantAgent: "goose",
},
{
name: "claude-code takes priority over goose",
env: map[string]string{"GOOSE_PROVIDER": "anthropic", "CLAUDECODE": "1"},
wantAgent: "claude-code",
},
{
name: "kiro takes priority over goose",
env: map[string]string{"GOOSE_PROVIDER": "anthropic", "TERM_PROGRAM": "kiro"},
wantAgent: "kiro",
},
{
name: "CLAUDE_CODE_IS_COWORK detected as cowork",
env: map[string]string{"CLAUDE_CODE_IS_COWORK": "1"},
wantAgent: "cowork",
},
{
name: "cowork takes priority over CLAUDECODE",
env: map[string]string{"CLAUDE_CODE_IS_COWORK": "1", "CLAUDECODE": "1"},
wantAgent: "cowork",
},
{
name: "CLAUDE_CODE",
env: map[string]string{"CLAUDE_CODE": "1"},
wantAgent: "claude-code",
},
{
name: "CURSOR_TRACE_ID detected as cursor",
env: map[string]string{"CURSOR_TRACE_ID": "abc"},
wantAgent: "cursor",
},
{
name: "CURSOR_AGENT detected as cursor-cli",
env: map[string]string{"CURSOR_AGENT": "1"},
wantAgent: "cursor-cli",
},
{
name: "CURSOR_EXTENSION_HOST_ROLE agent-exec detected as cursor-cli",
env: map[string]string{"CURSOR_EXTENSION_HOST_ROLE": "agent-exec"},
wantAgent: "cursor-cli",
},
{
name: "CURSOR_EXTENSION_HOST_ROLE with other value is ignored",
env: map[string]string{"CURSOR_EXTENSION_HOST_ROLE": "worker"},
wantAgent: "",
},
{
name: "CURSOR_TRACE_ID takes priority over CURSOR_AGENT",
env: map[string]string{"CURSOR_TRACE_ID": "abc", "CURSOR_AGENT": "1"},
wantAgent: "cursor",
},
{
name: "TERM_PROGRAM kiro detected as kiro",
env: map[string]string{"TERM_PROGRAM": "kiro"},
wantAgent: "kiro",
},
{
name: "TERM_PROGRAM with kiro as a substring is ignored",
env: map[string]string{"TERM_PROGRAM": "kirostudio"},
wantAgent: "",
},
{
name: "PATH containing .pi/agent detected as pi",
env: map[string]string{"PATH": "/usr/bin:/home/user/.pi/agent/bin"},
wantAgent: "pi",
},
{
name: "PATH with .pi/agent not on a path boundary is ignored",
env: map[string]string{"PATH": "/usr/bin:/home/user/x.pi/agent"},
wantAgent: "",
},
{
name: "PATH with Windows .pi\\agent separators detected as pi",
env: map[string]string{"PATH": `C:\Windows;C:\Users\user\.pi\agent\bin`},
wantAgent: "pi",
},
}

for _, tt := range tests {
Expand Down
69 changes: 57 additions & 12 deletions internal/prompter/huh_prompter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package prompter

import (
"io"
"sync"
"testing"
"time"

Expand All @@ -18,8 +19,9 @@ import (
// bubbletea event loop.

type interactionStep struct {
bytes []byte
delay time.Duration // pause before sending (lets the event loop settle)
bytes []byte
delay time.Duration // pause before sending (lets the event loop settle)
waitFn func() // if non-nil, called instead of time.Sleep(delay)
}

type interaction struct {
Expand All @@ -33,9 +35,15 @@ func newInteraction(steps ...interactionStep) interaction {
func (ix interaction) run(t *testing.T, w *io.PipeWriter) {
t.Helper()
for _, s := range ix.steps {
time.Sleep(s.delay)
_, err := w.Write(s.bytes)
require.NoError(t, err)
if s.waitFn != nil {
s.waitFn()
} else {
time.Sleep(s.delay)
}
if s.bytes != nil {
_, err := w.Write(s.bytes)
require.NoError(t, err)
}
}
}

Expand Down Expand Up @@ -98,11 +106,45 @@ func clearLine() interactionStep {
return interactionStep{bytes: []byte{0x01, 0x0b}}
}

// waitForOptions adds extra delay to let OptionsFunc load before continuing.
// waitForOptions adds extra delay to let the bubbletea event loop settle
// after switching modes when no async search is triggered.
func waitForOptions() interactionStep {
return interactionStep{bytes: nil, delay: 50 * time.Millisecond}
}

// waitForSearch returns an interactionStep that blocks until the field's
// current async search completes. It wires a one-shot callback into the
// field's onSearchDone hook and waits for it to fire, avoiding fixed-duration
// sleeps that are too short on slow architectures such as s390x under QEMU.
//
// The wait is bounded by the test's deadline (from -timeout) so a hung search
// fails the test with a clear message rather than blocking the whole test run.
func waitForSearch(t *testing.T, field *multiSelectSearchField) interactionStep {
t.Helper()
done := make(chan struct{})
var once sync.Once
field.onSearchDone.Store(func() {
once.Do(func() { close(done) })
})

var timeout <-chan time.Time
if deadline, ok := t.Deadline(); ok {
timeout = time.After(time.Until(deadline))
} else {
timeout = time.After(30 * time.Second)
}

return interactionStep{
waitFn: func() {
select {
case <-done:
case <-timeout:
t.Fatal("timed out waiting for async search to complete")
}
},
}
}

// --- Test harness ---

func newTestHuhPrompter() *huhPrompter {
Expand Down Expand Up @@ -475,13 +517,16 @@ func TestHuhPrompterMultiSelectWithSearchPersistence(t *testing.T) {
f, result := p.buildMultiSelectWithSearchForm(
"Select", "Search", nil, nil, staticSearchFunc,
)
// waitForSearch must be created before runForm so the hook is in place
// before the async search fires.
searchDone := waitForSearch(t, result)
runForm(t, f, newInteraction(
tab(), waitForOptions(),
toggle(), // toggle result-a
shiftTab(), // back to search input
typeKeys("foo"), // change query
tab(), waitForOptions(),
enter(), // submit — result-a should persist
tab(), waitForOptions(), // switch to select mode (no async search)
toggle(), // toggle result-a
shiftTab(), // back to search input
typeKeys("foo"), // change query
tab(), searchDone, // submit query → async search; wait for completion
enter(), // submit form — guaranteed search is done
))
assert.Equal(t, []string{"result-a"}, result.selectedKeys())
})
Expand Down
Loading
Loading