diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 50ba5aa9..649d89d1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -137,6 +137,23 @@ Based on [RFC 6749](https://tools.ietf.org/html/rfc6749) and [RFC 7636](https:// **Fallback:** - If refresh fails, prompt user to re-authenticate +## User Agent + +Custom user agent identifies pup CLI and detects AI coding assistants: + +**Format:** +``` +pup/v0.1.0 (go go1.25.0; os darwin; arch arm64) # Without agent +pup/v0.1.0 (go go1.25.0; os darwin; arch arm64) claude-code # With agent +``` + +**AI Agent Detection:** +- `CLAUDECODE=1` or `CLAUDE_CODE=1` → appends `claude-code` +- `CURSOR_AGENT=true` or `CURSOR_AGENT=1` → appends `cursor` +- Precedence: CLAUDECODE > CURSOR_AGENT + +**Implementation:** `pkg/useragent` - Separate package for reusability and testing. + ## API Client Wrapper ### Design Pattern diff --git a/pkg/client/client.go b/pkg/client/client.go index 4179415d..a3c8f506 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -10,13 +10,12 @@ import ( "fmt" "io" "net/http" - "runtime" "time" "github.com/DataDog/datadog-api-client-go/v2/api/datadog" - "github.com/DataDog/pup/internal/version" "github.com/DataDog/pup/pkg/auth/storage" "github.com/DataDog/pup/pkg/config" + "github.com/DataDog/pup/pkg/useragent" ) // Client wraps the Datadog API client @@ -74,7 +73,7 @@ func New(cfg *config.Config) (*Client, error) { configuration.Host = fmt.Sprintf("api.%s", cfg.Site) // Set custom user agent to identify requests as coming from pup CLI - configuration.UserAgent = getUserAgent() + configuration.UserAgent = useragent.Get() // Enable all unstable operations to suppress warnings // These are beta/preview features that we want to use @@ -136,7 +135,7 @@ func (c *Client) RawRequest(method, path string, body io.Reader) (*http.Response req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "application/json") - req.Header.Set("User-Agent", getUserAgent()) + req.Header.Set("User-Agent", useragent.Get()) // Set auth headers from context if token, ok := c.ctx.Value(datadog.ContextAccessToken).(string); ok && token != "" { @@ -159,13 +158,3 @@ func (c *Client) RawRequest(method, path string, body io.Reader) (*http.Response return resp, nil } -// getUserAgent returns a custom user agent string identifying the pup CLI -func getUserAgent() string { - return fmt.Sprintf( - "pup/%s (go %s; os %s; arch %s)", - version.Version, - runtime.Version(), - runtime.GOOS, - runtime.GOARCH, - ) -} diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 3ed4c73e..6485bbe2 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -10,7 +10,6 @@ import ( "io" "net/http" "net/http/httptest" - "runtime" "strings" "testing" @@ -18,6 +17,7 @@ import ( datadogV1 "github.com/DataDog/datadog-api-client-go/v2/api/datadogV1" "github.com/DataDog/pup/internal/version" "github.com/DataDog/pup/pkg/config" + "github.com/DataDog/pup/pkg/useragent" ) func TestNew_WithAPIKeys(t *testing.T) { @@ -494,7 +494,7 @@ func TestClient_APIConfiguration(t *testing.T) { } func TestGetUserAgent(t *testing.T) { - userAgent := getUserAgent() + userAgent := useragent.Get() // Check that it starts with "pup/" if !strings.HasPrefix(userAgent, "pup/") { @@ -506,21 +506,6 @@ func TestGetUserAgent(t *testing.T) { t.Errorf("User-Agent should contain version '%s', got: %s", version.Version, userAgent) } - // Check that it contains Go version - if !strings.Contains(userAgent, runtime.Version()) { - t.Errorf("User-Agent should contain Go version '%s', got: %s", runtime.Version(), userAgent) - } - - // Check that it contains OS - if !strings.Contains(userAgent, runtime.GOOS) { - t.Errorf("User-Agent should contain OS '%s', got: %s", runtime.GOOS, userAgent) - } - - // Check that it contains architecture - if !strings.Contains(userAgent, runtime.GOARCH) { - t.Errorf("User-Agent should contain arch '%s', got: %s", runtime.GOARCH, userAgent) - } - // Verify format: pup/ (go ; os ; arch ) if !strings.Contains(userAgent, "(go ") { t.Errorf("User-Agent should contain '(go ', got: %s", userAgent) @@ -614,7 +599,7 @@ func TestClient_IntegrationUserAgentInAPIClient(t *testing.T) { t.Errorf("User-Agent should start with 'pup/', got: %s", userAgent) } - expectedUA := getUserAgent() + expectedUA := useragent.Get() if userAgent != expectedUA { t.Errorf("User-Agent = %q, want %q", userAgent, expectedUA) } diff --git a/pkg/useragent/useragent.go b/pkg/useragent/useragent.go new file mode 100644 index 00000000..c6c3d0da --- /dev/null +++ b/pkg/useragent/useragent.go @@ -0,0 +1,62 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024-present Datadog, Inc. + +package useragent + +import ( + "fmt" + "os" + "runtime" + "strings" + + "github.com/DataDog/pup/internal/version" +) + +// Get returns the user agent string for pup CLI with optional AI agent detection. +// +// Format without agent: +// +// pup/v0.1.0 (go go1.25.0; os darwin; arch arm64) +// +// Format with agent: +// +// pup/v0.1.0 (go go1.25.0; os darwin; arch arm64) claude-code +// +// AI agents are detected via environment variables: +// - CLAUDECODE=1 or CLAUDE_CODE=1 → appends "claude-code" +// - CURSOR_AGENT=true or CURSOR_AGENT=1 → appends "cursor" +// +// If multiple agents are detected, CLAUDECODE takes precedence. +func Get() string { + base := fmt.Sprintf( + "pup/%s (go %s; os %s; arch %s)", + version.Version, + runtime.Version(), + runtime.GOOS, + runtime.GOARCH, + ) + + if agent := detectAgent(); agent != "" { + return base + " " + agent + } + return base +} + +// detectAgent detects AI coding assistant from environment variables. +// Returns empty string if no agent is detected. +func detectAgent() string { + // Check Claude Code (CLAUDECODE or CLAUDE_CODE) + if os.Getenv("CLAUDECODE") == "1" || os.Getenv("CLAUDE_CODE") == "1" { + return "claude-code" + } + + // Check Cursor (CURSOR_AGENT=true or CURSOR_AGENT=1) + cursorAgent := strings.ToLower(os.Getenv("CURSOR_AGENT")) + if cursorAgent == "true" || cursorAgent == "1" { + return "cursor" + } + + return "" +} diff --git a/pkg/useragent/useragent_test.go b/pkg/useragent/useragent_test.go new file mode 100644 index 00000000..5372a94a --- /dev/null +++ b/pkg/useragent/useragent_test.go @@ -0,0 +1,295 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2024-present Datadog, Inc. + +package useragent + +import ( + "os" + "runtime" + "strings" + "testing" + + "github.com/DataDog/pup/internal/version" +) + +func TestGet_NoAgent(t *testing.T) { + // Clear all agent environment variables + os.Unsetenv("CLAUDECODE") + os.Unsetenv("CLAUDE_CODE") + os.Unsetenv("CURSOR_AGENT") + + result := Get() + + // Check format matches: pup/VERSION (go GOVERSION; os OS; arch ARCH) + expected := "pup/" + version.Version + " (go " + runtime.Version() + "; os " + runtime.GOOS + "; arch " + runtime.GOARCH + ")" + if result != expected { + t.Errorf("Get() = %q, want %q", result, expected) + } + + // Verify no agent suffix + if strings.Contains(result, "claude-code") || strings.Contains(result, "cursor") { + t.Errorf("Get() should not contain agent suffix, got %q", result) + } +} + +func TestGet_WithClaudeCode(t *testing.T) { + tests := []struct { + name string + envVar string + envVal string + wantSuf string + }{ + {"CLAUDECODE=1", "CLAUDECODE", "1", "claude-code"}, + {"CLAUDE_CODE=1", "CLAUDE_CODE", "1", "claude-code"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear all agent env vars first + os.Unsetenv("CLAUDECODE") + os.Unsetenv("CLAUDE_CODE") + os.Unsetenv("CURSOR_AGENT") + + // Set test env var + os.Setenv(tt.envVar, tt.envVal) + defer os.Unsetenv(tt.envVar) + + result := Get() + + // Verify suffix is present + if !strings.HasSuffix(result, " "+tt.wantSuf) { + t.Errorf("Get() = %q, want suffix %q", result, tt.wantSuf) + } + + // Verify base format is still correct + expectedBase := "pup/" + version.Version + " (go " + runtime.Version() + "; os " + runtime.GOOS + "; arch " + runtime.GOARCH + ")" + if !strings.HasPrefix(result, expectedBase) { + t.Errorf("Get() = %q, want prefix %q", result, expectedBase) + } + }) + } +} + +func TestGet_WithCursor(t *testing.T) { + tests := []struct { + name string + envVal string + }{ + {"CURSOR_AGENT=true", "true"}, + {"CURSOR_AGENT=TRUE", "TRUE"}, + {"CURSOR_AGENT=1", "1"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear all agent env vars first + os.Unsetenv("CLAUDECODE") + os.Unsetenv("CLAUDE_CODE") + os.Unsetenv("CURSOR_AGENT") + + // Set test env var + os.Setenv("CURSOR_AGENT", tt.envVal) + defer os.Unsetenv("CURSOR_AGENT") + + result := Get() + + // Verify suffix is present + if !strings.HasSuffix(result, " cursor") { + t.Errorf("Get() = %q, want suffix 'cursor'", result) + } + + // Verify base format is still correct + expectedBase := "pup/" + version.Version + " (go " + runtime.Version() + "; os " + runtime.GOOS + "; arch " + runtime.GOARCH + ")" + if !strings.HasPrefix(result, expectedBase) { + t.Errorf("Get() = %q, want prefix %q", result, expectedBase) + } + }) + } +} + +func TestGet_WithMultipleAgents(t *testing.T) { + // Test precedence: CLAUDECODE should win when both are set + os.Unsetenv("CLAUDECODE") + os.Unsetenv("CLAUDE_CODE") + os.Unsetenv("CURSOR_AGENT") + + os.Setenv("CLAUDECODE", "1") + os.Setenv("CURSOR_AGENT", "true") + defer func() { + os.Unsetenv("CLAUDECODE") + os.Unsetenv("CURSOR_AGENT") + }() + + result := Get() + + // Should have claude-code, not cursor + if !strings.HasSuffix(result, " claude-code") { + t.Errorf("Get() = %q, want suffix 'claude-code' (CLAUDECODE should take precedence)", result) + } + if strings.Contains(result, "cursor") { + t.Errorf("Get() = %q, should not contain 'cursor' when CLAUDECODE is set", result) + } +} + +func TestDetectAgent(t *testing.T) { + tests := []struct { + name string + setup func() + teardown func() + want string + }{ + { + name: "no agent", + setup: func() { + os.Unsetenv("CLAUDECODE") + os.Unsetenv("CLAUDE_CODE") + os.Unsetenv("CURSOR_AGENT") + }, + teardown: func() {}, + want: "", + }, + { + name: "CLAUDECODE=1", + setup: func() { + os.Unsetenv("CLAUDECODE") + os.Unsetenv("CLAUDE_CODE") + os.Unsetenv("CURSOR_AGENT") + os.Setenv("CLAUDECODE", "1") + }, + teardown: func() { + os.Unsetenv("CLAUDECODE") + }, + want: "claude-code", + }, + { + name: "CLAUDE_CODE=1", + setup: func() { + os.Unsetenv("CLAUDECODE") + os.Unsetenv("CLAUDE_CODE") + os.Unsetenv("CURSOR_AGENT") + os.Setenv("CLAUDE_CODE", "1") + }, + teardown: func() { + os.Unsetenv("CLAUDE_CODE") + }, + want: "claude-code", + }, + { + name: "CURSOR_AGENT=true", + setup: func() { + os.Unsetenv("CLAUDECODE") + os.Unsetenv("CLAUDE_CODE") + os.Unsetenv("CURSOR_AGENT") + os.Setenv("CURSOR_AGENT", "true") + }, + teardown: func() { + os.Unsetenv("CURSOR_AGENT") + }, + want: "cursor", + }, + { + name: "CURSOR_AGENT=1", + setup: func() { + os.Unsetenv("CLAUDECODE") + os.Unsetenv("CLAUDE_CODE") + os.Unsetenv("CURSOR_AGENT") + os.Setenv("CURSOR_AGENT", "1") + }, + teardown: func() { + os.Unsetenv("CURSOR_AGENT") + }, + want: "cursor", + }, + { + name: "CURSOR_AGENT=false (invalid, should not detect)", + setup: func() { + os.Unsetenv("CLAUDECODE") + os.Unsetenv("CLAUDE_CODE") + os.Unsetenv("CURSOR_AGENT") + os.Setenv("CURSOR_AGENT", "false") + }, + teardown: func() { + os.Unsetenv("CURSOR_AGENT") + }, + want: "", + }, + { + name: "multiple agents (CLAUDECODE precedence)", + setup: func() { + os.Unsetenv("CLAUDECODE") + os.Unsetenv("CLAUDE_CODE") + os.Unsetenv("CURSOR_AGENT") + os.Setenv("CLAUDECODE", "1") + os.Setenv("CURSOR_AGENT", "true") + }, + teardown: func() { + os.Unsetenv("CLAUDECODE") + os.Unsetenv("CURSOR_AGENT") + }, + want: "claude-code", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tt.setup() + defer tt.teardown() + + got := detectAgent() + if got != tt.want { + t.Errorf("detectAgent() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestGet_Format(t *testing.T) { + // Clear all agent env vars + os.Unsetenv("CLAUDECODE") + os.Unsetenv("CLAUDE_CODE") + os.Unsetenv("CURSOR_AGENT") + + result := Get() + + // Verify format: pup/VERSION (go GOVERSION; os OS; arch ARCH) + if !strings.HasPrefix(result, "pup/") { + t.Errorf("Get() should start with 'pup/', got %q", result) + } + + if !strings.Contains(result, "(go ") { + t.Errorf("Get() should contain '(go ', got %q", result) + } + + if !strings.Contains(result, "; os ") { + t.Errorf("Get() should contain '; os ', got %q", result) + } + + if !strings.Contains(result, "; arch ") { + t.Errorf("Get() should contain '; arch ', got %q", result) + } + + // Verify no extra spaces or malformed output + if strings.Contains(result, " ") { + t.Errorf("Get() should not contain double spaces, got %q", result) + } + + // Test with agent + os.Setenv("CLAUDECODE", "1") + defer os.Unsetenv("CLAUDECODE") + + resultWithAgent := Get() + + // Verify agent is appended with single space + expectedSuffix := " claude-code" + if !strings.HasSuffix(resultWithAgent, expectedSuffix) { + t.Errorf("Get() with agent should end with %q, got %q", expectedSuffix, resultWithAgent) + } + + // Verify base part is unchanged + basePart := strings.TrimSuffix(resultWithAgent, expectedSuffix) + if basePart != result { + t.Errorf("Get() base part should be unchanged, got %q, want %q", basePart, result) + } +}