From a6b8572802f340e42a5193b30d5ba0e93a92d282 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Wed, 15 Jul 2026 12:10:01 -0700 Subject: [PATCH 1/3] feat(telemetry): add cli_context invocation detection --- pkg/telemetry/clicontext.go | 64 ++++++++++++++++++++++ pkg/telemetry/clicontext_test.go | 94 ++++++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 pkg/telemetry/clicontext.go create mode 100644 pkg/telemetry/clicontext_test.go diff --git a/pkg/telemetry/clicontext.go b/pkg/telemetry/clicontext.go new file mode 100644 index 00000000..bdbccaed --- /dev/null +++ b/pkg/telemetry/clicontext.go @@ -0,0 +1,64 @@ +package telemetry + +import ( + "os" + "strings" + + "github.com/algolia/cli/pkg/utils" +) + +const ( + ContextHuman = "human" + ContextAgentUnknown = "agent:unknown" +) + +var agentEnvVars = []struct { + envVar string + name string +}{ + {"CLAUDECODE", "claude-code"}, + {"CLAUDE_CODE", "claude-code"}, + {"CLAUDE_CODE_SESSION_ID", "claude-code"}, + {"CURSOR_AGENT", "cursor"}, + {"CODEX_THREAD_ID", "codex"}, + {"CODEX_SANDBOX", "codex"}, + {"CODEX_CI", "codex"}, + {"GEMINI_CLI", "gemini"}, + {"COPILOT_CLI", "github-copilot"}, + {"OPENCODE", "opencode"}, + {"OPENCODE_CLIENT", "opencode"}, + {"AMP_CURRENT_THREAD_ID", "amp"}, + {"AUGMENT_AGENT", "augment"}, + {"GOOSE_TERMINAL", "goose"}, + {"CLINE_ACTIVE", "cline"}, + {"ANTIGRAVITY_AGENT", "antigravity"}, + {"PI_CODING_AGENT", "pi"}, + {"KIRO_AGENT_PATH", "kiro"}, +} + +func DetectCLIContext() string { + return detectCLIContext( + os.Getenv, + utils.IsTerminal(os.Stdin), + utils.IsTerminal(os.Stdout), + utils.IsCI(), + ) +} + +func detectCLIContext(getenv func(string) string, stdinTTY, stdoutTTY, isCI bool) string { + for _, agent := range agentEnvVars { + if getenv(agent.envVar) != "" { + return "agent:" + agent.name + } + } + for _, key := range []string{"AI_AGENT", "AGENT"} { + v, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(getenv(key))), "_") + if v != "" { + return "agent:" + v + } + } + if !stdinTTY && !stdoutTTY && !isCI { + return ContextAgentUnknown + } + return ContextHuman +} diff --git a/pkg/telemetry/clicontext_test.go b/pkg/telemetry/clicontext_test.go new file mode 100644 index 00000000..b5ceee9b --- /dev/null +++ b/pkg/telemetry/clicontext_test.go @@ -0,0 +1,94 @@ +package telemetry + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func fakeEnv(vars map[string]string) func(string) string { + return func(key string) string { return vars[key] } +} + +func TestDetectCLIContext_KnownAgents(t *testing.T) { + cases := []struct { + envVar string + want string + }{ + {"CLAUDECODE", "agent:claude-code"}, + {"CLAUDE_CODE", "agent:claude-code"}, + {"CLAUDE_CODE_SESSION_ID", "agent:claude-code"}, + {"CURSOR_AGENT", "agent:cursor"}, + {"CODEX_THREAD_ID", "agent:codex"}, + {"CODEX_SANDBOX", "agent:codex"}, + {"CODEX_CI", "agent:codex"}, + {"GEMINI_CLI", "agent:gemini"}, + {"COPILOT_CLI", "agent:github-copilot"}, + {"OPENCODE", "agent:opencode"}, + {"OPENCODE_CLIENT", "agent:opencode"}, + {"AMP_CURRENT_THREAD_ID", "agent:amp"}, + {"AUGMENT_AGENT", "agent:augment"}, + {"GOOSE_TERMINAL", "agent:goose"}, + {"CLINE_ACTIVE", "agent:cline"}, + {"ANTIGRAVITY_AGENT", "agent:antigravity"}, + {"PI_CODING_AGENT", "agent:pi"}, + {"KIRO_AGENT_PATH", "agent:kiro"}, + } + for _, c := range cases { + t.Run(c.envVar, func(t *testing.T) { + got := detectCLIContext(fakeEnv(map[string]string{c.envVar: "1"}), true, true, false) + assert.Equal(t, c.want, got) + }) + } +} + +func TestDetectCLIContext_GenericAIAgentVar(t *testing.T) { + got := detectCLIContext(fakeEnv(map[string]string{"AI_AGENT": "SomeAgent"}), true, true, false) + assert.Equal(t, "agent:someagent", got) +} + +func TestDetectCLIContext_GenericAgentVar(t *testing.T) { + got := detectCLIContext(fakeEnv(map[string]string{"AGENT": "goose"}), true, true, false) + assert.Equal(t, "agent:goose", got) +} + +func TestDetectCLIContext_AIAgentWinsOverAgent(t *testing.T) { + env := fakeEnv(map[string]string{"AI_AGENT": "v0", "AGENT": "goose"}) + assert.Equal(t, "agent:v0", detectCLIContext(env, true, true, false)) +} + +func TestDetectCLIContext_WhitespaceOnlyGenericVar(t *testing.T) { + got := detectCLIContext(fakeEnv(map[string]string{"AI_AGENT": " "}), true, true, false) + assert.Equal(t, "human", got) +} + +func TestDetectCLIContext_VersionStampedGenericVar(t *testing.T) { + got := detectCLIContext(fakeEnv(map[string]string{"AI_AGENT": "claude-code_2-1-210_agent"}), true, true, false) + assert.Equal(t, "agent:claude-code", got) +} + +func TestDetectCLIContext_LeadingUnderscoreGenericVar(t *testing.T) { + got := detectCLIContext(fakeEnv(map[string]string{"AI_AGENT": "_foo"}), true, true, false) + assert.Equal(t, "human", got) +} + +func TestDetectCLIContext_NamedVarWinsOverGeneric(t *testing.T) { + env := fakeEnv(map[string]string{"CLAUDECODE": "1", "AI_AGENT": "other"}) + assert.Equal(t, "agent:claude-code", detectCLIContext(env, true, true, false)) +} + +func TestDetectCLIContext_NoTTYNoCI(t *testing.T) { + assert.Equal(t, "agent:unknown", detectCLIContext(fakeEnv(nil), false, false, false)) +} + +func TestDetectCLIContext_NoTTYInCI(t *testing.T) { + assert.Equal(t, "human", detectCLIContext(fakeEnv(nil), false, false, true)) +} + +func TestDetectCLIContext_PipedOutputOnly(t *testing.T) { + assert.Equal(t, "human", detectCLIContext(fakeEnv(nil), true, false, false)) +} + +func TestDetectCLIContext_Interactive(t *testing.T) { + assert.Equal(t, "human", detectCLIContext(fakeEnv(nil), true, true, false)) +} From b1da8c114a7d2f4485fd3c1c859dec57e7d1d15d Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Wed, 15 Jul 2026 12:10:01 -0700 Subject: [PATCH 2/3] feat(telemetry): emit cli_context property on all events --- pkg/telemetry/telemetry.go | 5 ++++- pkg/telemetry/telemetry_test.go | 26 ++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/pkg/telemetry/telemetry.go b/pkg/telemetry/telemetry.go index ff0168ec..2cdd108d 100644 --- a/pkg/telemetry/telemetry.go +++ b/pkg/telemetry/telemetry.go @@ -141,6 +141,7 @@ type CLIAnalyticsEventMetadata struct { CommandFlags []string // the command flags is the full list of flags passed to the command CLIVersion string // the version of the CLI OS string // the OS of the system + CLIContext string } // NewEventMetadata initializes an instance of CLIAnalyticsEventContext @@ -150,6 +151,7 @@ func NewEventMetadata() *CLIAnalyticsEventMetadata { InvocationID: uuid.NewRandom().String(), CLIVersion: version.Version, OS: runtime.GOOS, + CLIContext: DetectCLIContext(), } } @@ -263,7 +265,7 @@ func (a *AnalyticsTelemetryClient) Track( ) error { metadata := GetEventMetadata(ctx) - props := make(map[string]any, len(properties)+5) + props := make(map[string]any, len(properties)+6) for k, v := range properties { props[k] = v } @@ -273,6 +275,7 @@ func (a *AnalyticsTelemetryClient) Track( props["command"] = metadata.CommandPath props["flags"] = metadata.CommandFlags props["sequence"] = a.sequence.Add(1) + props["cli_context"] = metadata.CLIContext track := analytics.Track{ Event: event, diff --git a/pkg/telemetry/telemetry_test.go b/pkg/telemetry/telemetry_test.go index 95816218..0592efec 100644 --- a/pkg/telemetry/telemetry_test.go +++ b/pkg/telemetry/telemetry_test.go @@ -3,6 +3,7 @@ package telemetry import ( "context" "net/http" + "strings" "sync" "testing" @@ -215,6 +216,31 @@ func TestTrack_MergesCustomProperties(t *testing.T) { assert.Equal(t, "app-id", track.Properties["app_id"]) } +func TestTrack_IncludesCLIContext(t *testing.T) { + fake := &fakeAnalyticsClient{} + client := &AnalyticsTelemetryClient{client: fake} + + metadata := NewEventMetadata() + metadata.CLIContext = "agent:claude-code" + ctx := WithEventMetadata(context.Background(), metadata) + + require.NoError(t, client.Track(ctx, "Command Invoked", nil)) + require.Len(t, fake.messages, 1) + + track, ok := fake.messages[0].(analytics.Track) + require.True(t, ok) + assert.Equal(t, "agent:claude-code", track.Properties["cli_context"]) +} + +func TestNewEventMetadata_DetectsCLIContext(t *testing.T) { + metadata := NewEventMetadata() + assert.NotEmpty(t, metadata.CLIContext) + valid := metadata.CLIContext == ContextHuman || + metadata.CLIContext == ContextAgentUnknown || + strings.HasPrefix(metadata.CLIContext, "agent:") + assert.True(t, valid) +} + func TestTrack_SequenceIsMonotonic(t *testing.T) { fake := &fakeAnalyticsClient{} client := &AnalyticsTelemetryClient{client: fake} From dde595aa3e14bba8fd6c23b35401ba5e45737577 Mon Sep 17 00:00:00 2001 From: Lorris Saint-Genez Date: Wed, 15 Jul 2026 14:54:11 -0700 Subject: [PATCH 3/3] fix(telemetry): validate generic cli_context agent values --- pkg/telemetry/clicontext.go | 14 +++++++++++++- pkg/telemetry/clicontext_test.go | 15 +++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/pkg/telemetry/clicontext.go b/pkg/telemetry/clicontext.go index bdbccaed..2d190dc1 100644 --- a/pkg/telemetry/clicontext.go +++ b/pkg/telemetry/clicontext.go @@ -2,6 +2,7 @@ package telemetry import ( "os" + "regexp" "strings" "github.com/algolia/cli/pkg/utils" @@ -36,6 +37,17 @@ var agentEnvVars = []struct { {"KIRO_AGENT_PATH", "kiro"}, } +var genericAgentNameRe = regexp.MustCompile(`^[a-z][a-z0-9-]{0,31}$`) + +var booleanishValues = map[string]bool{ + "true": true, + "false": true, + "yes": true, + "no": true, + "on": true, + "off": true, +} + func DetectCLIContext() string { return detectCLIContext( os.Getenv, @@ -53,7 +65,7 @@ func detectCLIContext(getenv func(string) string, stdinTTY, stdoutTTY, isCI bool } for _, key := range []string{"AI_AGENT", "AGENT"} { v, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(getenv(key))), "_") - if v != "" { + if genericAgentNameRe.MatchString(v) && !booleanishValues[v] { return "agent:" + v } } diff --git a/pkg/telemetry/clicontext_test.go b/pkg/telemetry/clicontext_test.go index b5ceee9b..cb5c1fdd 100644 --- a/pkg/telemetry/clicontext_test.go +++ b/pkg/telemetry/clicontext_test.go @@ -72,6 +72,21 @@ func TestDetectCLIContext_LeadingUnderscoreGenericVar(t *testing.T) { assert.Equal(t, "human", got) } +func TestDetectCLIContext_NumericGenericVar(t *testing.T) { + got := detectCLIContext(fakeEnv(map[string]string{"AGENT": "1"}), true, true, false) + assert.Equal(t, "human", got) +} + +func TestDetectCLIContext_BooleanishGenericVar(t *testing.T) { + got := detectCLIContext(fakeEnv(map[string]string{"AGENT": "true"}), true, true, false) + assert.Equal(t, "human", got) +} + +func TestDetectCLIContext_OverlongGenericVar(t *testing.T) { + got := detectCLIContext(fakeEnv(map[string]string{"AI_AGENT": "a-very-long-agent-name-exceeding-32-characters"}), true, true, false) + assert.Equal(t, "human", got) +} + func TestDetectCLIContext_NamedVarWinsOverGeneric(t *testing.T) { env := fakeEnv(map[string]string{"CLAUDECODE": "1", "AI_AGENT": "other"}) assert.Equal(t, "agent:claude-code", detectCLIContext(env, true, true, false))