From 7c53e980fab08b06b05f881c39787b9166f3e2a9 Mon Sep 17 00:00:00 2001 From: Ramon Niebla Date: Fri, 9 Oct 2026 05:44:31 +0000 Subject: [PATCH 1/2] fix(analytics): detect agents from the variables that they set Most environment variables in known_agents.json were not real. No agent sets CLAUDE_CODE, CURSOR_SESSION_ID, CODEX_SESSION, DEVIN_SESSION, GITHUB_COPILOT, WINDSURF_SESSION, or CLINE_TASK_ID. Users set AIDER_MODEL themselves. Thus ldcli reported almost all agent runs as unknown-non-interactive. This change replaces the list with variables from the source code or documentation of each agent. It also reads AI_AGENT, a variable that many agents set. The value starts with the agent name, for example claude-code_2-1-295_agent. An unknown name becomes ai-agent:. An entry can now match a value with equals or contains. Amazon Q, Aider, and the Cursor terminal role need this. The tests use the variables that each agent sets, and they make sure that the old names do not match. Co-Authored-By: Claude Opus 5.5 --- cmd/analytics/analytics.go | 102 ++++++++++++- cmd/analytics/analytics_test.go | 252 +++++++++++++++++++++++++++----- cmd/analytics/known_agents.json | 48 ++++-- 3 files changed, 351 insertions(+), 51 deletions(-) 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", From 74bf0e897cec498b61b0596d0ddb5f6db3531734 Mon Sep 17 00:00:00 2001 From: Ramon Niebla Date: Fri, 9 Oct 2026 05:44:31 +0000 Subject: [PATCH 2/2] fix: send the completed event before ldcli exits with an error Execute called os.Exit(1) before it sent CLI Command Completed. os.Exit stops the process immediately, so ldcli did not send an error outcome after version 1.16.2. Execute now sends the event, waits for it, and then exits with code 1. Co-Authored-By: Claude Opus 5.5 --- cmd/root.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 0503d70b5..1f4bc9a9d 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -409,10 +409,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 } @@ -435,6 +431,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.