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
17 changes: 17 additions & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 3 additions & 14 deletions pkg/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 != "" {
Expand All @@ -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,
)
}
21 changes: 3 additions & 18 deletions pkg/client/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,14 @@ import (
"io"
"net/http"
"net/http/httptest"
"runtime"
"strings"
"testing"

"github.com/DataDog/datadog-api-client-go/v2/api/datadog"
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) {
Expand Down Expand Up @@ -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/") {
Expand All @@ -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/<version> (go <version>; os <os>; arch <arch>)
if !strings.Contains(userAgent, "(go ") {
t.Errorf("User-Agent should contain '(go ', got: %s", userAgent)
Expand Down Expand Up @@ -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)
}
Expand Down
62 changes: 62 additions & 0 deletions pkg/useragent/useragent.go
Original file line number Diff line number Diff line change
@@ -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 ""
}
Loading