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
76 changes: 76 additions & 0 deletions pkg/telemetry/clicontext.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package telemetry

import (
"os"
"regexp"
"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"},
}

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,
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"} {
Comment thread
LorrisSaintGenez marked this conversation as resolved.
v, _, _ := strings.Cut(strings.ToLower(strings.TrimSpace(getenv(key))), "_")
if genericAgentNameRe.MatchString(v) && !booleanishValues[v] {
return "agent:" + v
}
}
if !stdinTTY && !stdoutTTY && !isCI {
return ContextAgentUnknown
}
return ContextHuman
}
109 changes: 109 additions & 0 deletions pkg/telemetry/clicontext_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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_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))
}

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))
}
5 changes: 4 additions & 1 deletion pkg/telemetry/telemetry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -150,6 +151,7 @@ func NewEventMetadata() *CLIAnalyticsEventMetadata {
InvocationID: uuid.NewRandom().String(),
CLIVersion: version.Version,
OS: runtime.GOOS,
CLIContext: DetectCLIContext(),
}
}

Expand Down Expand Up @@ -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
}
Expand All @@ -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,
Expand Down
26 changes: 26 additions & 0 deletions pkg/telemetry/telemetry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package telemetry
import (
"context"
"net/http"
"strings"
"sync"
"testing"

Expand Down Expand Up @@ -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}
Expand Down
Loading