diff --git a/cmd/analytics/analytics.go b/cmd/analytics/analytics.go index 62d4a0e51..b4dd48eda 100644 --- a/cmd/analytics/analytics.go +++ b/cmd/analytics/analytics.go @@ -4,6 +4,7 @@ import ( _ "embed" "encoding/json" "os" + "strings" "github.com/launchdarkly/ldcli/cmd/cliflags" @@ -30,19 +31,48 @@ func (osEnvChecker) StdoutFd() int { return int(os.Stdout.Fd()) } //go:embed known_agents.json var knownAgentsJSON []byte +// agentEnvVar maps an environment variable to an agent label. If Equals or +// Contains is set, the value must also match. Otherwise any non-empty value +// matches. +// agentEnvVar maps an environment variable to an agent label. If Equals or +// Contains is set, the value must also match. Otherwise any non-empty value +// matches. type agentEnvVar struct { - EnvVar string `json:"env_var"` + EnvVar string `json:"env_var"` + Equals string `json:"equals,omitempty"` + Contains string `json:"contains,omitempty"` + Label string `json:"label"` +} + +func (a agentEnvVar) matches(value string) bool { + switch { + case value == "": + return false + case a.Equals != "": + return value == a.Equals + case a.Contains != "": + return strings.Contains(value, a.Contains) + default: + return true + } +} + +// aiAgentPrefix maps the start of an AI_AGENT value to an agent label. +type aiAgentPrefix struct { + Prefix string `json:"prefix"` Label string `json:"label"` } type knownAgentsConfig struct { - Agents []agentEnvVar `json:"agents"` - CIEnvVars []string `json:"ci_env_vars"` + AIAgentPrefixes []aiAgentPrefix `json:"ai_agent_prefixes"` + Agents []agentEnvVar `json:"agents"` + CIEnvVars []string `json:"ci_env_vars"` } var ( - knownAgentEnvVars []agentEnvVar - knownCIEnvVars []string + knownAIAgentPrefixes []aiAgentPrefix + knownAgentEnvVars []agentEnvVar + knownCIEnvVars []string ) func init() { @@ -50,6 +80,7 @@ func init() { if err := json.Unmarshal(knownAgentsJSON, &cfg); err != nil { panic("failed to parse embedded known_agents.json: " + err.Error()) } + knownAIAgentPrefixes = cfg.AIAgentPrefixes knownAgentEnvVars = cfg.Agents knownCIEnvVars = cfg.CIEnvVars } @@ -65,8 +96,12 @@ func detectAgentContext(env envChecker) string { return "explicit:" + v } + if label := aiAgentLabel(env.Getenv("AI_AGENT")); label != "" { + return label + } + for _, a := range knownAgentEnvVars { - if env.Getenv(a.EnvVar) != "" { + if a.matches(env.Getenv(a.EnvVar)) { return a.Label } } @@ -84,6 +119,61 @@ func detectAgentContext(env envChecker) string { return "unknown-non-interactive" } +// maxAIAgentNameLen limits the length of an unknown AI_AGENT name in a label. +const maxAIAgentNameLen = 32 + +// aiAgentLabel converts an AI_AGENT value to a label. AI_AGENT is a shared +// convention: the value starts with the agent name and can have a version +// after it, for example "claude-code_2-1-295_agent" or "devin@1". A known +// name gets its label. An unknown name gets "ai-agent:", without the +// version. The function returns "" if the value has no usable name. +func aiAgentLabel(value string) string { + value = strings.ToLower(strings.TrimSpace(value)) + if value == "" { + return "" + } + + for _, p := range knownAIAgentPrefixes { + if hasNamePrefix(value, p.Prefix) { + return p.Label + } + } + + name, _, _ := strings.Cut(value, "@") + name, _, _ = strings.Cut(name, "_") + name = strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + return r + } + return -1 + }, name) + if len(name) > maxAIAgentNameLen { + name = name[:maxAIAgentNameLen] + } + if name == "" { + return "" + } + + return "ai-agent:" + name +} + +// hasNamePrefix reports whether value starts with prefix and the prefix ends at +// a name boundary. For example, "amp" matches "amp" and "amp_1" but not "ampere". +func hasNamePrefix(value, prefix string) bool { + if !strings.HasPrefix(value, prefix) { + return false + } + if len(value) == len(prefix) { + return true + } + switch value[len(prefix)] { + case '_', '@', '-': + return true + default: + return false + } +} + func CmdRunEventProperties( cmd *cobra.Command, name string, diff --git a/cmd/analytics/analytics_test.go b/cmd/analytics/analytics_test.go index 34d6fd337..969390174 100644 --- a/cmd/analytics/analytics_test.go +++ b/cmd/analytics/analytics_test.go @@ -1,6 +1,7 @@ package analytics import ( + "strings" "testing" "github.com/spf13/cobra" @@ -39,65 +40,236 @@ func TestDetectAgentContext(t *testing.T) { expected: "explicit:my-skill", }, { - name: "explicit LD_CLI_AGENT takes precedence over known env vars", - env: newMockEnv(map[string]string{"LD_CLI_AGENT": "custom", "CURSOR_SESSION_ID": "abc"}, false), + name: "explicit LD_CLI_AGENT takes precedence over AI_AGENT and known env vars", + env: newMockEnv(map[string]string{"LD_CLI_AGENT": "custom", "AI_AGENT": "devin_1_agent", "CLAUDECODE": "1"}, false), expected: "explicit:custom", }, + + // Environment variables that each agent sets in the shell that runs ldcli. + { + name: "Claude Code", + env: newMockEnv(map[string]string{ + "AI_AGENT": "claude-code_2-1-295_agent", + "CLAUDECODE": "1", + "CLAUDE_CODE_ENTRYPOINT": "cli", + "CLAUDE_CODE_SESSION_ID": "1366a0bd-a5d9-4aef-a2b1-e96a3a313a34", + }, false), + expected: "claude-code", + }, + { + name: "Claude Code before AI_AGENT support", + env: newMockEnv(map[string]string{"CLAUDECODE": "1", "CLAUDE_CODE_ENTRYPOINT": "cli"}, false), + expected: "claude-code", + }, { - name: "CURSOR_SESSION_ID detected", - env: newMockEnv(map[string]string{"CURSOR_SESSION_ID": "abc"}, false), + name: "Cursor agent", + env: newMockEnv(map[string]string{"CURSOR_AGENT": "1"}, false), expected: "cursor", }, { - name: "CURSOR_TRACE_ID detected", - env: newMockEnv(map[string]string{"CURSOR_TRACE_ID": "xyz"}, false), + name: "Cursor agent terminal role", + env: newMockEnv(map[string]string{"CURSOR_EXTENSION_HOST_ROLE": "agent-exec"}, false), expected: "cursor", }, { - name: "CLAUDE_CODE detected", - env: newMockEnv(map[string]string{"CLAUDE_CODE": "1"}, false), - expected: "claude-code", + name: "Cursor builds that also set CI=1", + env: newMockEnv(map[string]string{"CURSOR_AGENT": "1", "CI": "1"}, false), + expected: "cursor", }, { - name: "CLAUDE_CODE_SESSION detected", - env: newMockEnv(map[string]string{"CLAUDE_CODE_SESSION": "sess"}, false), - expected: "claude-code", + name: "Codex CLI", + env: newMockEnv(map[string]string{ + "CODEX_THREAD_ID": "t-1", + "CODEX_SESSION_ID": "s-1", + "CODEX_SANDBOX_NETWORK_DISABLED": "1", + }, false), + expected: "codex", }, { - name: "CODEX_SESSION detected", - env: newMockEnv(map[string]string{"CODEX_SESSION": "s"}, false), + name: "Codex CLI unified exec", + env: newMockEnv(map[string]string{"CODEX_CI": "1"}, false), expected: "codex", }, { - name: "CODEX_SANDBOX_ID detected", - env: newMockEnv(map[string]string{"CODEX_SANDBOX_ID": "sb"}, false), + name: "Codex CLI macOS sandbox", + env: newMockEnv(map[string]string{"CODEX_SANDBOX": "seatbelt"}, false), expected: "codex", }, { - name: "DEVIN_SESSION detected", - env: newMockEnv(map[string]string{"DEVIN_SESSION": "d"}, false), + name: "Devin", + env: newMockEnv(map[string]string{"AI_AGENT": "devin_3000-11-1_agent"}, false), expected: "devin", }, { - name: "GITHUB_COPILOT detected", - env: newMockEnv(map[string]string{"GITHUB_COPILOT": "1"}, false), + name: "GitHub Copilot in VS Code", + env: newMockEnv(map[string]string{ + "AI_AGENT": "github_copilot_vscode_agent", + "COPILOT_AGENT": "1", + "TERM_PROGRAM": "vscode", + }, true), expected: "copilot", }, { - name: "WINDSURF_SESSION detected", - env: newMockEnv(map[string]string{"WINDSURF_SESSION": "w"}, false), - expected: "windsurf", + name: "GitHub Copilot CLI", + env: newMockEnv(map[string]string{"COPILOT_CLI": "1", "COPILOT_AGENT_SESSION_ID": "s-1"}, false), + expected: "copilot", + }, + { + name: "Gemini CLI", + env: newMockEnv(map[string]string{"GEMINI_CLI": "1"}, false), + expected: "gemini-cli", + }, + { + name: "OpenCode", + env: newMockEnv(map[string]string{"OPENCODE": "1", "AGENT": "1", "OPENCODE_PID": "42"}, false), + expected: "opencode", + }, + { + name: "Kilo Code also sets OPENCODE", + env: newMockEnv(map[string]string{"KILO": "1", "KILOCODE_FEATURE": "cli", "OPENCODE": "1", "AGENT": "1"}, false), + expected: "kilo-code", + }, + { + name: "Crush", + env: newMockEnv(map[string]string{"CRUSH": "1", "AGENT": "crush", "AI_AGENT": "crush"}, false), + expected: "crush", + }, + { + name: "Augment", + env: newMockEnv(map[string]string{"AUGMENT_AGENT": "1"}, false), + expected: "augment", }, { - name: "CLINE_TASK_ID detected", - env: newMockEnv(map[string]string{"CLINE_TASK_ID": "t"}, false), + name: "Cline in a VS Code terminal", + env: newMockEnv(map[string]string{"CLINE_ACTIVE": "true", "TERM_PROGRAM": "vscode"}, true), expected: "cline", }, { - name: "AIDER_MODEL detected", - env: newMockEnv(map[string]string{"AIDER_MODEL": "gpt-4"}, false), + name: "Roo Code in a VS Code terminal", + env: newMockEnv(map[string]string{"ROO_ACTIVE": "true"}, true), + expected: "roo-code", + }, + { + name: "Roo Code CLI", + env: newMockEnv(map[string]string{"ROO_CLI_RUNTIME": "1"}, false), + expected: "roo-code", + }, + { + name: "Amp", + env: newMockEnv(map[string]string{"AMP_CURRENT_THREAD_ID": "T-1"}, false), + expected: "amp", + }, + { + name: "Windsurf Cascade", + env: newMockEnv(map[string]string{"WINDSURF_CASCADE_TERMINAL": "1"}, false), + expected: "windsurf", + }, + { + name: "Amazon Q Developer CLI", + env: newMockEnv(map[string]string{"AWS_EXECUTION_ENV": "AmazonQ-For-CLI Version/1.12.0"}, false), + expected: "amazon-q", + }, + { + name: "Aider", + env: newMockEnv(map[string]string{"OR_APP_NAME": "Aider", "OR_SITE_URL": "https://aider.chat"}, false), expected: "aider", }, + + // AI_AGENT parsing. + { + name: "AI_AGENT takes precedence over agent env vars", + env: newMockEnv(map[string]string{"AI_AGENT": "claude-code_2-1-295_agent", "CODEX_THREAD_ID": "t-1"}, false), + expected: "claude-code", + }, + { + name: "AI_AGENT with an @ version", + env: newMockEnv(map[string]string{"AI_AGENT": "devin@1"}, false), + expected: "devin", + }, + { + name: "AI_AGENT is not case sensitive", + env: newMockEnv(map[string]string{"AI_AGENT": " Claude-Code_2-1-295_agent "}, false), + expected: "claude-code", + }, + { + name: "AI_AGENT with an unknown name drops the version", + env: newMockEnv(map[string]string{"AI_AGENT": "hermes-agent_0-4_agent"}, false), + expected: "ai-agent:hermes-agent", + }, + { + name: "AI_AGENT prefix must end at a name boundary", + env: newMockEnv(map[string]string{"AI_AGENT": "ampere"}, false), + expected: "ai-agent:ampere", + }, + { + name: "AI_AGENT removes characters that are not allowed", + env: newMockEnv(map[string]string{"AI_AGENT": "my agent!/../x"}, false), + expected: "ai-agent:myagentx", + }, + { + name: "AI_AGENT with a long unknown name is cut", + env: newMockEnv(map[string]string{"AI_AGENT": "abcdefghijklmnopqrstuvwxyz0123456789"}, false), + expected: "ai-agent:abcdefghijklmnopqrstuvwxyz012345", + }, + { + name: "AI_AGENT with no usable name is ignored", + env: newMockEnv(map[string]string{"AI_AGENT": "_@!!"}, false), + expected: "unknown-non-interactive", + }, + { + name: "AI_AGENT that is only spaces is ignored", + env: newMockEnv(map[string]string{"AI_AGENT": " "}, true), + expected: "", + }, + + // Values that must not match. + { + name: "AWS_EXECUTION_ENV without AmazonQ is not an agent", + env: newMockEnv(map[string]string{"AWS_EXECUTION_ENV": "AWS_Lambda_go1.x"}, false), + expected: "unknown-non-interactive", + }, + { + name: "OR_APP_NAME for another app is not an agent", + env: newMockEnv(map[string]string{"OR_APP_NAME": "SomeOtherApp"}, false), + expected: "unknown-non-interactive", + }, + { + name: "CURSOR_EXTENSION_HOST_ROLE for a person is not an agent", + env: newMockEnv(map[string]string{"CURSOR_EXTENSION_HOST_ROLE": "user"}, true), + expected: "", + }, + { + name: "person in the Cursor terminal is not an agent", + env: newMockEnv(map[string]string{"CURSOR_TRACE_ID": "xyz", "TERM_PROGRAM": "vscode"}, true), + expected: "", + }, + { + name: "person with AIDER_MODEL in the shell profile is not an agent", + env: newMockEnv(map[string]string{"AIDER_MODEL": "gpt-4"}, true), + expected: "", + }, + { + name: "Replit workspace is not an agent", + env: newMockEnv(map[string]string{"REPL_ID": "r-1"}, true), + expected: "", + }, + { + name: "environment variables that no agent sets are not detected", + env: newMockEnv(map[string]string{ + "CLAUDE_CODE": "1", + "CLAUDE_CODE_SESSION": "s", + "CURSOR_SESSION_ID": "c", + "CODEX_SESSION": "s", + "CODEX_SANDBOX_ID": "sb", + "DEVIN_SESSION": "d", + "GITHUB_COPILOT": "1", + "WINDSURF_SESSION": "w", + "CLINE_TASK_ID": "t", + }, false), + expected: "unknown-non-interactive", + }, + + // Terminal and CI fallbacks. { name: "no TTY and no env vars returns unknown-non-interactive", env: newMockEnv(map[string]string{}, false), @@ -120,8 +292,8 @@ func TestDetectAgentContext(t *testing.T) { }, { name: "agent env var takes precedence over CI env var", - env: newMockEnv(map[string]string{"CURSOR_SESSION_ID": "abc", "CI": "true"}, false), - expected: "cursor", + env: newMockEnv(map[string]string{"COPILOT_AGENT": "1", "GITHUB_ACTIONS": "true", "CI": "true"}, false), + expected: "copilot", }, { name: "interactive terminal with no agent env vars returns empty", @@ -133,11 +305,6 @@ func TestDetectAgentContext(t *testing.T) { env: newMockEnv(map[string]string{"CI": "true"}, true), expected: "", }, - { - name: "first matching env var wins by priority order", - env: newMockEnv(map[string]string{"CURSOR_SESSION_ID": "c", "CLAUDE_CODE": "1"}, false), - expected: "cursor", - }, { name: "stdin is TTY but stdout is not still counts as interactive", env: mockEnvChecker{envVars: map[string]string{}, stdinTerminal: true, stdoutTerminal: false}, @@ -158,6 +325,23 @@ func TestDetectAgentContext(t *testing.T) { } } +func TestKnownAgentsConfig(t *testing.T) { + assert.NotEmpty(t, knownAIAgentPrefixes) + assert.NotEmpty(t, knownAgentEnvVars) + assert.NotEmpty(t, knownCIEnvVars) + + for _, p := range knownAIAgentPrefixes { + assert.NotEmpty(t, p.Prefix) + assert.NotEmpty(t, p.Label, "prefix %q", p.Prefix) + assert.Equal(t, strings.ToLower(p.Prefix), p.Prefix, "prefix %q must be lowercase", p.Prefix) + } + for _, a := range knownAgentEnvVars { + assert.NotEmpty(t, a.EnvVar) + assert.NotEmpty(t, a.Label, "env var %q", a.EnvVar) + assert.False(t, a.Equals != "" && a.Contains != "", "env var %q sets both equals and contains", a.EnvVar) + } +} + func TestCmdRunEventProperties(t *testing.T) { t.Run("does not set agent_context itself", func(t *testing.T) { cmd := &cobra.Command{Use: "test"} diff --git a/cmd/analytics/known_agents.json b/cmd/analytics/known_agents.json index 507296fcd..7dda6939a 100644 --- a/cmd/analytics/known_agents.json +++ b/cmd/analytics/known_agents.json @@ -1,16 +1,42 @@ { + "ai_agent_prefixes": [ + {"prefix": "claude-code", "label": "claude-code"}, + {"prefix": "github_copilot", "label": "copilot"}, + {"prefix": "github-copilot", "label": "copilot"}, + {"prefix": "copilot", "label": "copilot"}, + {"prefix": "cursor", "label": "cursor"}, + {"prefix": "codex", "label": "codex"}, + {"prefix": "devin", "label": "devin"}, + {"prefix": "gemini", "label": "gemini-cli"}, + {"prefix": "kilo", "label": "kilo-code"}, + {"prefix": "opencode", "label": "opencode"}, + {"prefix": "crush", "label": "crush"}, + {"prefix": "amp", "label": "amp"}, + {"prefix": "goose", "label": "goose"}, + {"prefix": "openhands", "label": "openhands"} + ], "agents": [ - {"env_var": "CURSOR_SESSION_ID", "label": "cursor"}, - {"env_var": "CURSOR_TRACE_ID", "label": "cursor"}, - {"env_var": "CLAUDE_CODE", "label": "claude-code"}, - {"env_var": "CLAUDE_CODE_SESSION", "label": "claude-code"}, - {"env_var": "CODEX_SESSION", "label": "codex"}, - {"env_var": "CODEX_SANDBOX_ID", "label": "codex"}, - {"env_var": "DEVIN_SESSION", "label": "devin"}, - {"env_var": "GITHUB_COPILOT", "label": "copilot"}, - {"env_var": "WINDSURF_SESSION", "label": "windsurf"}, - {"env_var": "CLINE_TASK_ID", "label": "cline"}, - {"env_var": "AIDER_MODEL", "label": "aider"} + {"env_var": "CLAUDECODE", "label": "claude-code"}, + {"env_var": "CURSOR_AGENT", "label": "cursor"}, + {"env_var": "CURSOR_EXTENSION_HOST_ROLE", "equals": "agent-exec", "label": "cursor"}, + {"env_var": "CODEX_THREAD_ID", "label": "codex"}, + {"env_var": "CODEX_SESSION_ID", "label": "codex"}, + {"env_var": "CODEX_CI", "label": "codex"}, + {"env_var": "CODEX_SANDBOX", "label": "codex"}, + {"env_var": "GEMINI_CLI", "label": "gemini-cli"}, + {"env_var": "KILO", "label": "kilo-code"}, + {"env_var": "OPENCODE", "label": "opencode"}, + {"env_var": "CRUSH", "label": "crush"}, + {"env_var": "COPILOT_AGENT", "label": "copilot"}, + {"env_var": "COPILOT_CLI", "label": "copilot"}, + {"env_var": "AUGMENT_AGENT", "label": "augment"}, + {"env_var": "CLINE_ACTIVE", "label": "cline"}, + {"env_var": "ROO_ACTIVE", "label": "roo-code"}, + {"env_var": "ROO_CLI_RUNTIME", "label": "roo-code"}, + {"env_var": "AMP_CURRENT_THREAD_ID", "label": "amp"}, + {"env_var": "WINDSURF_CASCADE_TERMINAL", "label": "windsurf"}, + {"env_var": "AWS_EXECUTION_ENV", "contains": "AmazonQ", "label": "amazon-q"}, + {"env_var": "OR_APP_NAME", "equals": "Aider", "label": "aider"} ], "ci_env_vars": [ "CI", diff --git a/cmd/root.go b/cmd/root.go index dd9266bd3..e86795e2e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -412,10 +412,6 @@ See each command's help for details on how to use the generated script.`, rootCm case err != nil: outcome = analytics.ERROR fmt.Fprintln(os.Stderr, err.Error()) - // Give the background check a moment to write the cache. os.Exit - // would otherwise kill it immediately. - waitForUpdateNotice() - os.Exit(1) default: outcome = analytics.SUCCESS } @@ -438,6 +434,12 @@ See each command's help for details on how to use the generated script.`, rootCm analyticsClient.Wait() waitForUpdateNotice() + + // Exit after the completed event is sent. os.Exit stops the process + // immediately, so an earlier exit lost the error event. + if outcome == analytics.ERROR { + os.Exit(1) + } } // setFlagsFromConfig reads in the config file if it exists and uses any flag values for commands.