Detect which AI coding agent is invoking gh by checking well-known environment variables and include the agent name in the User-Agent header sent to GitHub APIs. Supported agents: Codex, Gemini CLI, Copilot CLI, OpenCode, Claude Code, and Amp. Generic AI_AGENT env var is also supported with validation to prevent header injection. Fixes github/cli#1111 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
99 lines
3.2 KiB
Go
99 lines
3.2 KiB
Go
package agents
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"regexp"
|
|
)
|
|
|
|
// 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"
|
|
)
|
|
|
|
var validAgentName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
|
|
|
|
// parseAgentName validates and returns an AgentName from a raw string.
|
|
// Only alphanumeric characters, hyphens, and underscores are allowed.
|
|
func parseAgentName(s string) (AgentName, error) {
|
|
if !validAgentName.MatchString(s) {
|
|
return "", fmt.Errorf("invalid agent name %q: must match [a-zA-Z0-9_-]+", s)
|
|
}
|
|
return AgentName(s), nil
|
|
}
|
|
|
|
// Detect returns the name of the AI coding agent driving the CLI,
|
|
// or an empty AgentName if none is detected.
|
|
func Detect() AgentName {
|
|
return detectWith(os.LookupEnv)
|
|
}
|
|
|
|
func detectWith(lookup func(string) (string, bool)) AgentName {
|
|
isSet := func(key string) bool {
|
|
v, ok := lookup(key)
|
|
return ok && v != ""
|
|
}
|
|
|
|
valueOf := func(key string) string {
|
|
v, _ := lookup(key)
|
|
return v
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|
|
|
|
// Tool-specific variables.
|
|
|
|
// Check AGENT=amp before the more generic CLAUDECODE=1 since Amp sets both.
|
|
if valueOf("AGENT") == "amp" {
|
|
return agentAmp
|
|
}
|
|
|
|
// 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
|
|
// GEMINI_CLI: https://github.com/google-gemini/gemini-cli/blob/46fd7b4864111032a1c7dfa1821b2000fc7531da/docs/tools/shell.md#L96-L97
|
|
if isSet("GEMINI_CLI") {
|
|
return agentGeminiCLI
|
|
}
|
|
|
|
// GitHub Copilot CLI
|
|
// No first-party docs
|
|
if isSet("COPILOT_CLI") {
|
|
return agentCopilotCLI
|
|
}
|
|
|
|
// OpenCode — https://github.com/anomalyco/opencode
|
|
// OPENCODE: https://github.com/anomalyco/opencode/blob/fde201c286a83ff32dda9b41d61d734a4449fe70/packages/opencode/src/index.ts#L78-L80
|
|
if isSet("OPENCODE") {
|
|
return agentOpencode
|
|
}
|
|
|
|
// 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") {
|
|
// 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
|
|
}
|
|
|
|
return ""
|
|
}
|