From 7d8663ef46b6012db9cf30b71c3913888e4ab117 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 28 Jan 2026 13:27:12 -0800 Subject: [PATCH 1/6] Detects popular AI agents and defaults to no-prompt mode --- cli/azd/cmd/auto_install.go | 15 +- cli/azd/cmd/auto_install_integration_test.go | 113 ++++++ cli/azd/cmd/auto_install_test.go | 77 ++++ cli/azd/internal/global_command_options.go | 4 +- .../internal/runcontext/agentdetect/detect.go | 66 ++++ .../runcontext/agentdetect/detect_env.go | 155 ++++++++ .../runcontext/agentdetect/detect_process.go | 156 ++++++++ .../agentdetect/detect_process_darwin.go | 74 ++++ .../agentdetect/detect_process_linux.go | 79 ++++ .../agentdetect/detect_process_windows.go | 111 ++++++ .../runcontext/agentdetect/detect_test.go | 348 ++++++++++++++++++ .../internal/runcontext/agentdetect/types.go | 126 +++++++ cli/azd/internal/tracing/fields/fields.go | 16 + .../tracing/resource/exec_environment.go | 50 +++ 14 files changed, 1388 insertions(+), 2 deletions(-) create mode 100644 cli/azd/internal/runcontext/agentdetect/detect.go create mode 100644 cli/azd/internal/runcontext/agentdetect/detect_env.go create mode 100644 cli/azd/internal/runcontext/agentdetect/detect_process.go create mode 100644 cli/azd/internal/runcontext/agentdetect/detect_process_darwin.go create mode 100644 cli/azd/internal/runcontext/agentdetect/detect_process_linux.go create mode 100644 cli/azd/internal/runcontext/agentdetect/detect_process_windows.go create mode 100644 cli/azd/internal/runcontext/agentdetect/detect_test.go create mode 100644 cli/azd/internal/runcontext/agentdetect/types.go diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index b9da1b850c3..3d005016016 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect" "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/input" @@ -491,7 +492,8 @@ func CreateGlobalFlagSet() *pflag.FlagSet { globalFlags.Bool( "no-prompt", false, - "Accepts the default value instead of prompting, or it fails if there is no default.") + "Accepts the default value instead of prompting, or it fails if there is no default. "+ + "Automatically enabled when running inside AI coding agents (Claude Code, Cursor, GitHub Copilot CLI, etc.).") // The telemetry system is responsible for reading these flags value and using it to configure the telemetry // system, but we still need to add it to our flag set so that when we parse the command line with Cobra we @@ -509,6 +511,9 @@ func CreateGlobalFlagSet() *pflag.FlagSet { // Uses ParseErrorsAllowlist to gracefully ignore unknown flags (like extension-specific flags). // This function is designed to be called BEFORE Cobra command tree construction to enable // early access to global flag values for auto-install and other pre-execution logic. +// +// Agent Detection: If --no-prompt is not explicitly set and an AI coding agent (like Claude Code, +// GitHub Copilot CLI, Cursor, etc.) is detected as the caller, NoPrompt is automatically enabled. func ParseGlobalFlags(args []string, opts *internal.GlobalCommandOptions) error { globalFlagSet := CreateGlobalFlagSet() @@ -542,5 +547,13 @@ func ParseGlobalFlags(args []string, opts *internal.GlobalCommandOptions) error opts.NoPrompt = boolVal } + // Agent Detection: If --no-prompt was not explicitly set and we detect an AI coding agent + // as the caller, automatically enable no-prompt mode for non-interactive execution. + noPromptFlag := globalFlagSet.Lookup("no-prompt") + noPromptExplicitlySet := noPromptFlag != nil && noPromptFlag.Changed + if !noPromptExplicitlySet && agentdetect.IsRunningInAgent() { + opts.NoPrompt = true + } + return nil } diff --git a/cli/azd/cmd/auto_install_integration_test.go b/cli/azd/cmd/auto_install_integration_test.go index 45719e41d8e..af7645c621b 100644 --- a/cli/azd/cmd/auto_install_integration_test.go +++ b/cli/azd/cmd/auto_install_integration_test.go @@ -7,8 +7,11 @@ import ( "os" "testing" + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestExecuteWithAutoInstallIntegration tests the integration between @@ -72,3 +75,113 @@ func TestExecuteWithAutoInstallIntegration(t *testing.T) { // Restore original args os.Args = originalArgs } + +// TestAgentDetectionIntegration tests the full agent detection integration flow. +func TestAgentDetectionIntegration(t *testing.T) { + tests := []struct { + name string + args []string + envVars map[string]string + expectedNoPrompt bool + description string + }{ + { + name: "Claude Code agent enables no-prompt automatically", + args: []string{"version"}, + envVars: map[string]string{"CLAUDE_CODE": "1"}, + expectedNoPrompt: true, + description: "When running under Claude Code, --no-prompt should be auto-enabled", + }, + { + name: "Cursor agent enables no-prompt automatically", + args: []string{"up"}, + envVars: map[string]string{"CURSOR_EDITOR": "1"}, + expectedNoPrompt: true, + description: "When running under Cursor, --no-prompt should be auto-enabled", + }, + { + name: "GitHub Copilot CLI enables no-prompt automatically", + args: []string{"deploy"}, + envVars: map[string]string{"GITHUB_COPILOT_CLI": "true"}, + expectedNoPrompt: true, + description: "When running under GitHub Copilot CLI, --no-prompt should be auto-enabled", + }, + { + name: "Windsurf agent enables no-prompt automatically", + args: []string{"init"}, + envVars: map[string]string{"WINDSURF_EDITOR": "1"}, + expectedNoPrompt: true, + description: "When running under Windsurf, --no-prompt should be auto-enabled", + }, + { + name: "Aider agent enables no-prompt automatically", + args: []string{"provision"}, + envVars: map[string]string{"AIDER_MODEL": "gpt-4"}, + expectedNoPrompt: true, + description: "When running under Aider, --no-prompt should be auto-enabled", + }, + { + name: "User can override agent detection with --no-prompt=false", + args: []string{"--no-prompt=false", "up"}, + envVars: map[string]string{"CLAUDE_CODE": "1"}, + expectedNoPrompt: false, + description: "Explicit --no-prompt=false should override agent detection", + }, + { + name: "Normal execution without agent detection", + args: []string{"version"}, + envVars: map[string]string{}, + expectedNoPrompt: false, + description: "Without agent detection, prompting should remain enabled by default", + }, + { + name: "User agent string triggers detection", + args: []string{"up"}, + envVars: map[string]string{ + internal.AzdUserAgentEnvVar: "claude-code/1.0.0", + }, + expectedNoPrompt: true, + description: "AZURE_DEV_USER_AGENT containing agent identifier should trigger detection", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset agent detection cache for each test + agentdetect.ResetDetection() + + // Set environment variables + for k, v := range tt.envVars { + t.Setenv(k, v) + } + + // Parse global flags as would happen in real execution + opts := &internal.GlobalCommandOptions{} + err := ParseGlobalFlags(tt.args, opts) + require.NoError(t, err, "ParseGlobalFlags should not error: %s", tt.description) + + assert.Equal(t, tt.expectedNoPrompt, opts.NoPrompt, + "NoPrompt mismatch: %s", tt.description) + + // Verify agent detection status matches expectation + agent := agentdetect.GetCallingAgent() + if tt.expectedNoPrompt && len(tt.envVars) > 0 && !containsNoPromptFalse(tt.args) { + assert.True(t, agent.Detected, + "Agent should be detected when agent env vars are set: %s", tt.description) + } + + // Clean up + agentdetect.ResetDetection() + }) + } +} + +// containsNoPromptFalse checks if args contain --no-prompt=false +func containsNoPromptFalse(args []string) bool { + for _, arg := range args { + if arg == "--no-prompt=false" { + return true + } + } + return false +} diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index 2ffc0428b7a..7fed3183ff3 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -7,9 +7,12 @@ import ( "strings" "testing" + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect" "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestFindFirstNonFlagArg(t *testing.T) { @@ -327,3 +330,77 @@ func TestCheckForMatchingExtension_Unit(t *testing.T) { }) } } + +func TestParseGlobalFlags_AgentDetection(t *testing.T) { + tests := []struct { + name string + args []string + envVars map[string]string + expectedNoPrompt bool + }{ + { + name: "no agent detected, no flag", + args: []string{"up"}, + envVars: map[string]string{}, + expectedNoPrompt: false, + }, + { + name: "agent detected via env var, no flag", + args: []string{"up"}, + envVars: map[string]string{"CLAUDE_CODE": "1"}, + expectedNoPrompt: true, + }, + { + name: "agent detected but --no-prompt=false explicitly set", + args: []string{"--no-prompt=false", "up"}, + envVars: map[string]string{"CLAUDE_CODE": "1"}, + expectedNoPrompt: false, + }, + { + name: "agent detected but --no-prompt explicitly set true", + args: []string{"--no-prompt", "up"}, + envVars: map[string]string{"CURSOR_EDITOR": "1"}, + expectedNoPrompt: true, + }, + { + name: "no agent, --no-prompt explicitly set", + args: []string{"--no-prompt", "deploy"}, + envVars: map[string]string{}, + expectedNoPrompt: true, + }, + { + name: "Cursor agent detected", + args: []string{"init"}, + envVars: map[string]string{"CURSOR_EDITOR": "1"}, + expectedNoPrompt: true, + }, + { + name: "GitHub Copilot CLI agent detected", + args: []string{"deploy"}, + envVars: map[string]string{"GITHUB_COPILOT_CLI": "true"}, + expectedNoPrompt: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Reset agent detection cache + agentdetect.ResetDetection() + + // Set up env vars for this test + for k, v := range tt.envVars { + t.Setenv(k, v) + } + + opts := &internal.GlobalCommandOptions{} + err := ParseGlobalFlags(tt.args, opts) + require.NoError(t, err) + + assert.Equal(t, tt.expectedNoPrompt, opts.NoPrompt, + "NoPrompt should be %v for test case: %s", tt.expectedNoPrompt, tt.name) + + // Clean up for next test + agentdetect.ResetDetection() + }) + } +} diff --git a/cli/azd/internal/global_command_options.go b/cli/azd/internal/global_command_options.go index 742fe421490..81d15c5b2c5 100644 --- a/cli/azd/internal/global_command_options.go +++ b/cli/azd/internal/global_command_options.go @@ -14,8 +14,10 @@ type GlobalCommandOptions struct { // launched tools. It's enabled with `--debug`, for any command. EnableDebugLogging bool - // when true, interactive prompts should behave as if the user selected the default value. + // NoPrompt when true, interactive prompts should behave as if the user selected the default value. // if there is no default value the prompt returns an error. + // This is automatically set to true when an AI coding agent (Claude Code, Cursor, GitHub Copilot CLI, + // Windsurf, Aider, etc.) is detected as the caller, unless explicitly set to false with --no-prompt=false. NoPrompt bool // EnableTelemetry indicates if telemetry should be sent. diff --git a/cli/azd/internal/runcontext/agentdetect/detect.go b/cli/azd/internal/runcontext/agentdetect/detect.go new file mode 100644 index 00000000000..cc97317077c --- /dev/null +++ b/cli/azd/internal/runcontext/agentdetect/detect.go @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agentdetect + +import ( + "log" + "sync" +) + +var ( + cachedAgent AgentInfo + detectOnce sync.Once +) + +// GetCallingAgent detects if azd was invoked by a known AI coding agent. +// The result is cached after the first call. +func GetCallingAgent() AgentInfo { + detectOnce.Do(func() { + cachedAgent = detectAgent() + if cachedAgent.Detected { + log.Printf("Agent detection result: detected=%t, agent=%s, source=%s, details=%s", + cachedAgent.Detected, cachedAgent.Name, cachedAgent.Source, cachedAgent.Details) + } else { + log.Printf("Agent detection result: detected=%t, no AI coding agent detected", + cachedAgent.Detected) + } + }) + return cachedAgent +} + +// IsRunningInAgent returns true if azd was invoked by a known AI coding agent. +func IsRunningInAgent() bool { + return GetCallingAgent().Detected +} + +// detectAgent performs the actual agent detection. +// Detection is performed in priority order: +// 1. Environment variables (most reliable) +// 2. User agent string (AZURE_DEV_USER_AGENT) +// 3. Parent process inspection (fallback) +func detectAgent() AgentInfo { + // Try environment variable detection first (most reliable) + if agent := detectFromEnvVars(); agent.Detected { + return agent + } + + // Try user agent string detection + if agent := detectFromUserAgent(); agent.Detected { + return agent + } + + // Try parent process detection as fallback + if agent := detectFromParentProcess(); agent.Detected { + return agent + } + + return NoAgent() +} + +// ResetDetection clears the cached detection result. +// This is primarily useful for testing. +func ResetDetection() { + detectOnce = sync.Once{} + cachedAgent = AgentInfo{} +} diff --git a/cli/azd/internal/runcontext/agentdetect/detect_env.go b/cli/azd/internal/runcontext/agentdetect/detect_env.go new file mode 100644 index 00000000000..7fef6c68cf6 --- /dev/null +++ b/cli/azd/internal/runcontext/agentdetect/detect_env.go @@ -0,0 +1,155 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agentdetect + +import ( + "os" + "strings" + + "github.com/azure/azure-dev/cli/azd/internal" +) + +// agentEnvVarPatterns maps environment variables to agent types. +// Each entry defines env vars that indicate a specific agent is running. +type envVarPattern struct { + envVar string + agentType AgentType + // checkValue optionally validates the env var value (if empty, presence is enough) + checkValue string +} + +// knownEnvVarPatterns defines environment variables that indicate known AI agents. +// These are checked in order, so more specific patterns should come first. +var knownEnvVarPatterns = []envVarPattern{ + // Claude Code - Anthropic's coding agent + {envVar: "CLAUDE_CODE", agentType: AgentTypeClaudeCode}, + {envVar: "CLAUDE_CODE_ENTRYPOINT", agentType: AgentTypeClaudeCode}, + + // GitHub Copilot CLI + {envVar: "GITHUB_COPILOT_CLI", agentType: AgentTypeGitHubCopilotCLI}, + {envVar: "GH_COPILOT", agentType: AgentTypeGitHubCopilotCLI}, + + // OpenAI Codex CLI + {envVar: "OPENAI_CODEX", agentType: AgentTypeOpenAICodex}, + {envVar: "CODEX_CLI", agentType: AgentTypeOpenAICodex}, + + // Cursor editor - VS Code fork with AI + {envVar: "CURSOR_EDITOR", agentType: AgentTypeCursor}, + {envVar: "CURSOR_SESSION_ID", agentType: AgentTypeCursor}, + {envVar: "CURSOR_TRACE_ID", agentType: AgentTypeCursor}, + + // Windsurf editor (by Codeium) - VS Code fork + {envVar: "WINDSURF_EDITOR", agentType: AgentTypeWindsurf}, + {envVar: "WINDSURF_SESSION", agentType: AgentTypeWindsurf}, + + // Zed editor - Rust-based editor with AI + {envVar: "ZED_TERM", agentType: AgentTypeZed}, + + // Aider - AI pair programming tool + {envVar: "AIDER_MODEL", agentType: AgentTypeAider}, + {envVar: "AIDER_CHAT_LANGUAGE", agentType: AgentTypeAider}, + {envVar: "AIDER_OPENAI_API_KEY", agentType: AgentTypeAider}, + + // Continue coding assistant + {envVar: "CONTINUE_GLOBAL_DIR", agentType: AgentTypeContinue}, + {envVar: "CONTINUE_DEVELOPMENT", agentType: AgentTypeContinue}, + + // Amazon Q Developer (formerly CodeWhisperer) + {envVar: "AMAZON_Q_DEVELOPER", agentType: AgentTypeAmazonQ}, + {envVar: "AWS_Q_DEVELOPER", agentType: AgentTypeAmazonQ}, + {envVar: "CODEWHISPERER_TOKEN", agentType: AgentTypeAmazonQ}, + {envVar: "KIRO_CLI", agentType: AgentTypeAmazonQ}, + + // Cline (formerly Claude Dev) - VS Code extension + {envVar: "CLINE_API_KEY", agentType: AgentTypeCline}, + {envVar: "CLINE_MCP", agentType: AgentTypeCline}, + + // Tabnine - AI code completion + {envVar: "TABNINE_TOKEN", agentType: AgentTypeTabnine}, + {envVar: "TABNINE_CONFIG", agentType: AgentTypeTabnine}, + + // Cody (Sourcegraph) - AI coding assistant + {envVar: "SRC_ACCESS_TOKEN", agentType: AgentTypeCody}, + {envVar: "CODY_CONFIG", agentType: AgentTypeCody}, + + // Google Gemini CLI + {envVar: "GEMINI_CLI", agentType: AgentTypeGemini}, + {envVar: "GEMINI_CLI_NO_RELAUNCH", agentType: AgentTypeGemini}, + {envVar: "GEMINI_API_KEY", agentType: AgentTypeGemini}, + {envVar: "GOOGLE_GEMINI_API_KEY", agentType: AgentTypeGemini}, + {envVar: "GEMINI_CODE_ASSIST", agentType: AgentTypeGemini}, +} + +// detectFromEnvVars checks for known AI agent environment variables. +func detectFromEnvVars() AgentInfo { + for _, pattern := range knownEnvVarPatterns { + if value, exists := os.LookupEnv(pattern.envVar); exists { + // If checkValue is specified, verify it matches + if pattern.checkValue != "" && value != pattern.checkValue { + continue + } + + return AgentInfo{ + Type: pattern.agentType, + Name: pattern.agentType.DisplayName(), + Source: DetectionSourceEnvVar, + Detected: true, + Details: pattern.envVar, + } + } + } + + return NoAgent() +} + +// userAgentPatterns maps user agent substrings to agent types. +// Matched case-insensitively against AZURE_DEV_USER_AGENT. +var userAgentPatterns = []struct { + substring string + agentType AgentType +}{ + // VS Code GitHub Copilot extension + {substring: internal.VsCodeAzureCopilotAgentPrefix, agentType: AgentTypeVSCodeCopilot}, + {substring: "github-copilot", agentType: AgentTypeGitHubCopilotCLI}, + {substring: "copilot-cli", agentType: AgentTypeGitHubCopilotCLI}, + {substring: "claude-code", agentType: AgentTypeClaudeCode}, + {substring: "claude", agentType: AgentTypeClaudeCode}, + {substring: "cursor", agentType: AgentTypeCursor}, + {substring: "windsurf", agentType: AgentTypeWindsurf}, + {substring: "aider", agentType: AgentTypeAider}, + {substring: "amazon-q", agentType: AgentTypeAmazonQ}, + {substring: "kiro", agentType: AgentTypeAmazonQ}, + {substring: "cline", agentType: AgentTypeCline}, + {substring: "zed", agentType: AgentTypeZed}, + {substring: "tabnine", agentType: AgentTypeTabnine}, + {substring: "cody", agentType: AgentTypeCody}, + {substring: "sourcegraph", agentType: AgentTypeCody}, + {substring: "gemini", agentType: AgentTypeGemini}, + {substring: "codex", agentType: AgentTypeOpenAICodex}, + {substring: "continue", agentType: AgentTypeContinue}, +} + +// detectFromUserAgent checks the AZURE_DEV_USER_AGENT env var for known agents. +func detectFromUserAgent() AgentInfo { + userAgent := os.Getenv(internal.AzdUserAgentEnvVar) + if userAgent == "" { + return NoAgent() + } + + userAgentLower := strings.ToLower(userAgent) + + for _, pattern := range userAgentPatterns { + if strings.Contains(userAgentLower, strings.ToLower(pattern.substring)) { + return AgentInfo{ + Type: pattern.agentType, + Name: pattern.agentType.DisplayName(), + Source: DetectionSourceUserAgent, + Detected: true, + Details: userAgent, + } + } + } + + return NoAgent() +} diff --git a/cli/azd/internal/runcontext/agentdetect/detect_process.go b/cli/azd/internal/runcontext/agentdetect/detect_process.go new file mode 100644 index 00000000000..85a46b69d94 --- /dev/null +++ b/cli/azd/internal/runcontext/agentdetect/detect_process.go @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agentdetect + +import ( + "os" + "path/filepath" + "strings" +) + +// processNamePatterns maps process name patterns to agent types. +// Patterns are matched case-insensitively against process names and executable paths. +var processNamePatterns = []struct { + patterns []string // lowercase patterns to match + agentType AgentType +}{ + // Claude Code (Anthropic) - installed via npm, homebrew, or direct download + { + patterns: []string{"claude", "claude-code"}, + agentType: AgentTypeClaudeCode, + }, + // GitHub Copilot CLI - installed via npm (@github/copilot) or as gh extension + { + patterns: []string{"copilot", "copilot-cli", "gh-copilot", "github-copilot", "github-copilot-cli"}, + agentType: AgentTypeGitHubCopilotCLI, + }, + // OpenAI Codex CLI - Rust-based CLI + { + patterns: []string{"codex", "openai-codex"}, + agentType: AgentTypeOpenAICodex, + }, + // Cursor Editor - VS Code fork with AI + { + patterns: []string{"cursor"}, + agentType: AgentTypeCursor, + }, + // Windsurf Editor (by Codeium) - VS Code fork + { + patterns: []string{"windsurf"}, + agentType: AgentTypeWindsurf, + }, + // Aider - AI pair programming tool (Python-based, may appear as python with aider in args) + { + patterns: []string{"aider", "aider-chat"}, + agentType: AgentTypeAider, + }, + // Continue - AI coding assistant (CLI is 'cn' command) + { + patterns: []string{"continue", "cn"}, + agentType: AgentTypeContinue, + }, + // Amazon Q Developer (formerly CodeWhisperer) - CLI is 'q', also 'kiro' for new version + { + patterns: []string{"amazon-q", "q-developer", "chat_cli", "kiro"}, + agentType: AgentTypeAmazonQ, + }, + // Cline (formerly Claude Dev) - VS Code extension, may have CLI + { + patterns: []string{"cline", "claude-dev"}, + agentType: AgentTypeCline, + }, + // Zed Editor - Rust-based editor with AI features + { + patterns: []string{"zed"}, + agentType: AgentTypeZed, + }, + // Tabnine - AI code completion + { + patterns: []string{"tabnine", "tabnine-companion"}, + agentType: AgentTypeTabnine, + }, + // Cody (Sourcegraph) - AI coding assistant + { + patterns: []string{"cody", "sourcegraph"}, + agentType: AgentTypeCody, + }, + // Google Gemini CLI + { + patterns: []string{"gemini", "gemini-code", "google-gemini"}, + agentType: AgentTypeGemini, + }, +} + +// maxProcessTreeDepth limits how far up the process tree we walk to prevent infinite loops. +const maxProcessTreeDepth = 10 + +// detectFromParentProcess checks if any ancestor process is a known AI agent. +// It walks up the process tree to find agents that spawn intermediate shells. +func detectFromParentProcess() AgentInfo { + currentPid := os.Getppid() + + for depth := 0; depth < maxProcessTreeDepth && currentPid > 1; depth++ { + info, parentPid, err := getParentProcessInfoWithPPID(currentPid) + if err != nil { + break + } + + // Check if this process matches a known agent + agent := matchProcessToAgent(info) + if agent.Detected { + return agent + } + + // Move up the tree + if parentPid <= 1 || parentPid == currentPid { + // Reached root or stuck in a loop + break + } + currentPid = parentPid + } + + return NoAgent() +} + +// parentProcessInfo contains information about the parent process. +type parentProcessInfo struct { + // Name is the process name (e.g., "claude" or "cursor.exe") + Name string + // Executable is the full path to the executable (if available) + Executable string + // CommandLine is the full command line (if available) + CommandLine string +} + +// matchProcessToAgent matches process info against known agent patterns. +func matchProcessToAgent(info parentProcessInfo) AgentInfo { + // Normalize for matching + nameLower := strings.ToLower(info.Name) + exeLower := strings.ToLower(filepath.Base(info.Executable)) + + // Remove common extensions for matching + nameLower = strings.TrimSuffix(nameLower, ".exe") + exeLower = strings.TrimSuffix(exeLower, ".exe") + + for _, pattern := range processNamePatterns { + for _, p := range pattern.patterns { + if strings.Contains(nameLower, p) || strings.Contains(exeLower, p) { + matchedOn := info.Name + if info.Executable != "" { + matchedOn = info.Executable + } + + return AgentInfo{ + Type: pattern.agentType, + Name: pattern.agentType.DisplayName(), + Source: DetectionSourceParentProcess, + Detected: true, + Details: matchedOn, + } + } + } + } + + return NoAgent() +} diff --git a/cli/azd/internal/runcontext/agentdetect/detect_process_darwin.go b/cli/azd/internal/runcontext/agentdetect/detect_process_darwin.go new file mode 100644 index 00000000000..7c09a758f3a --- /dev/null +++ b/cli/azd/internal/runcontext/agentdetect/detect_process_darwin.go @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build darwin + +package agentdetect + +import ( + "bytes" + "fmt" + "os/exec" + "strconv" + "strings" +) + +// getParentProcessInfoWithPPID retrieves information about a process and its parent PID on macOS. +// It uses the ps command which is universally available on macOS. +func getParentProcessInfoWithPPID(pid int) (parentProcessInfo, int, error) { + info := parentProcessInfo{} + parentPid := 0 + + // Use ps to get process info and parent PID + // -o comm= gives just the command name, -o ppid= gives parent PID + cmd := exec.Command("ps", "-p", fmt.Sprintf("%d", pid), "-o", "comm=,ppid=") + output, err := cmd.Output() + if err != nil { + return info, 0, fmt.Errorf("failed to get process info: %w", err) + } + + // Parse output: "process_name ppid" + parts := strings.Fields(strings.TrimSpace(string(output))) + if len(parts) >= 1 { + info.Name = parts[0] + } + if len(parts) >= 2 { + parentPid, _ = strconv.Atoi(parts[1]) + } + + // Get the full command path + cmd = exec.Command("ps", "-p", fmt.Sprintf("%d", pid), "-o", "args=") + output, err = cmd.Output() + if err == nil { + cmdLine := strings.TrimSpace(string(output)) + info.CommandLine = cmdLine + + // Extract executable path (first argument) + cmdParts := strings.Fields(cmdLine) + if len(cmdParts) > 0 { + info.Executable = cmdParts[0] + } + } + + // If we couldn't get the executable from args, try lsof + if info.Executable == "" { + cmd = exec.Command("lsof", "-p", fmt.Sprintf("%d", pid), "-Fn") + output, err = cmd.Output() + if err == nil { + // Parse lsof output - lines starting with 'n' contain file names + lines := bytes.Split(output, []byte("\n")) + for _, line := range lines { + if len(line) > 1 && line[0] == 'n' { + path := string(line[1:]) + // Skip non-file entries + if strings.HasPrefix(path, "/") && !strings.Contains(path, " ") { + info.Executable = path + break + } + } + } + } + } + + return info, parentPid, nil +} diff --git a/cli/azd/internal/runcontext/agentdetect/detect_process_linux.go b/cli/azd/internal/runcontext/agentdetect/detect_process_linux.go new file mode 100644 index 00000000000..38e117dd3b2 --- /dev/null +++ b/cli/azd/internal/runcontext/agentdetect/detect_process_linux.go @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build linux + +package agentdetect + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// getParentProcessInfoWithPPID retrieves information about a process and its parent PID on Linux. +// It reads from /proc filesystem which is always available on Linux. +func getParentProcessInfoWithPPID(pid int) (parentProcessInfo, int, error) { + info := parentProcessInfo{} + parentPid := 0 + + // Read process name from /proc/{pid}/comm + commPath := fmt.Sprintf("/proc/%d/comm", pid) + commData, err := os.ReadFile(commPath) + if err != nil { + return info, 0, fmt.Errorf("failed to read process comm: %w", err) + } + info.Name = strings.TrimSpace(string(commData)) + + // Read executable path from /proc/{pid}/exe symlink + exePath := fmt.Sprintf("/proc/%d/exe", pid) + exe, err := os.Readlink(exePath) + if err == nil { + info.Executable = exe + } + // exe may fail due to permissions, that's ok + + // Read command line from /proc/{pid}/cmdline + cmdlinePath := fmt.Sprintf("/proc/%d/cmdline", pid) + cmdlineData, err := os.ReadFile(cmdlinePath) + if err == nil { + // cmdline is null-separated + info.CommandLine = strings.ReplaceAll(string(cmdlineData), "\x00", " ") + info.CommandLine = strings.TrimSpace(info.CommandLine) + } + + // Read parent PID from /proc/{pid}/stat + statPath := fmt.Sprintf("/proc/%d/stat", pid) + statData, err := os.ReadFile(statPath) + if err == nil { + parentPid = parseParentPidFromStat(string(statData)) + } + + return info, parentPid, nil +} + +// parseParentPidFromStat extracts the parent PID from /proc/{pid}/stat content. +// Format: pid (comm) state ppid ... +// The comm field can contain spaces and parentheses, so we find the last ')' first. +func parseParentPidFromStat(stat string) int { + // Find the last ')' which ends the comm field + lastParen := strings.LastIndex(stat, ")") + if lastParen == -1 || lastParen+2 >= len(stat) { + return 0 + } + + // After ') ' comes: state ppid ... + rest := stat[lastParen+2:] + fields := strings.Fields(rest) + if len(fields) < 2 { + return 0 + } + + // fields[0] is state, fields[1] is ppid + ppid, err := strconv.Atoi(fields[1]) + if err != nil { + return 0 + } + return ppid +} diff --git a/cli/azd/internal/runcontext/agentdetect/detect_process_windows.go b/cli/azd/internal/runcontext/agentdetect/detect_process_windows.go new file mode 100644 index 00000000000..2f5fcca9e9a --- /dev/null +++ b/cli/azd/internal/runcontext/agentdetect/detect_process_windows.go @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build windows + +package agentdetect + +import ( + "fmt" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// getParentProcessInfoWithPPID retrieves information about a process and its parent PID on Windows. +func getParentProcessInfoWithPPID(pid int) (parentProcessInfo, int, error) { + info := parentProcessInfo{} + parentPid := 0 + + // Open the process with query rights + //nolint:gosec // G115: pid is validated before use + handle, err := windows.OpenProcess( + windows.PROCESS_QUERY_LIMITED_INFORMATION, + false, + uint32(pid), + ) + if err != nil { + return info, 0, fmt.Errorf("failed to open process %d: %w", pid, err) + } + defer windows.CloseHandle(handle) + + // Get the executable path + exePath, err := getProcessImageName(handle) + if err == nil { + info.Executable = exePath + info.Name = getBaseName(exePath) + } + + // Get parent PID using NtQueryInformationProcess + parentPid, err = getParentPid(pid) + if err != nil { + // Non-fatal - we still have the process info + parentPid = 0 + } + + return info, parentPid, nil +} + +// getParentPid retrieves the parent process ID using process snapshot. +func getParentPid(pid int) (int, error) { + // Use CreateToolhelp32Snapshot to enumerate processes + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return 0, fmt.Errorf("CreateToolhelp32Snapshot failed: %w", err) + } + defer windows.CloseHandle(snapshot) + + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + + err = windows.Process32First(snapshot, &entry) + if err != nil { + return 0, fmt.Errorf("Process32First failed: %w", err) + } + + for { + if int(entry.ProcessID) == pid { + return int(entry.ParentProcessID), nil + } + err = windows.Process32Next(snapshot, &entry) + if err != nil { + break + } + } + + return 0, fmt.Errorf("process %d not found", pid) +} + +// getProcessImageName retrieves the full path of the executable for a process. +func getProcessImageName(handle windows.Handle) (string, error) { + // Start with a reasonable buffer size + bufSize := uint32(windows.MAX_PATH) + buf := make([]uint16, bufSize) + + err := windows.QueryFullProcessImageName(handle, 0, &buf[0], &bufSize) + if err != nil { + // Try with a larger buffer + bufSize = 32768 + buf = make([]uint16, bufSize) + err = windows.QueryFullProcessImageName(handle, 0, &buf[0], &bufSize) + if err != nil { + return "", fmt.Errorf("QueryFullProcessImageName failed: %w", err) + } + } + + return syscall.UTF16ToString(buf[:bufSize]), nil +} + +// getBaseName extracts the file name from a full path. +func getBaseName(path string) string { + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '\\' || path[i] == '/' { + return path[i+1:] + } + } + return path +} + +// Ensure we have the right imports for unsafe operations +var _ = unsafe.Sizeof(0) diff --git a/cli/azd/internal/runcontext/agentdetect/detect_test.go b/cli/azd/internal/runcontext/agentdetect/detect_test.go new file mode 100644 index 00000000000..a5cb18f3aa1 --- /dev/null +++ b/cli/azd/internal/runcontext/agentdetect/detect_test.go @@ -0,0 +1,348 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agentdetect + +import ( + "os" + "testing" + + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAgentType_DisplayName(t *testing.T) { + tests := []struct { + agentType AgentType + displayName string + }{ + {AgentTypeClaudeCode, "Claude Code"}, + {AgentTypeGitHubCopilotCLI, "GitHub Copilot CLI"}, + {AgentTypeOpenAICodex, "OpenAI Codex"}, + {AgentTypeCursor, "Cursor"}, + {AgentTypeWindsurf, "Windsurf"}, + {AgentTypeAider, "Aider"}, + {AgentTypeContinue, "Continue"}, + {AgentTypeAmazonQ, "Amazon Q Developer"}, + {AgentTypeVSCodeCopilot, "VS Code GitHub Copilot"}, + {AgentTypeCline, "Cline"}, + {AgentTypeZed, "Zed"}, + {AgentTypeTabnine, "Tabnine"}, + {AgentTypeCody, "Cody"}, + {AgentTypeGeneric, "Generic Agent"}, + {AgentTypeUnknown, "Unknown"}, + } + + for _, tt := range tests { + t.Run(string(tt.agentType), func(t *testing.T) { + assert.Equal(t, tt.displayName, tt.agentType.DisplayName()) + }) + } +} + +func TestNoAgent(t *testing.T) { + agent := NoAgent() + assert.False(t, agent.Detected) + assert.Equal(t, AgentTypeUnknown, agent.Type) + assert.Empty(t, agent.Name) + assert.Equal(t, DetectionSourceNone, agent.Source) +} + +func TestDetectFromEnvVars(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + expectedAgent AgentType + detected bool + }{ + { + name: "No env vars", + envVars: map[string]string{}, + expectedAgent: AgentTypeUnknown, + detected: false, + }, + { + name: "Claude Code via CLAUDE_CODE", + envVars: map[string]string{"CLAUDE_CODE": "1"}, + expectedAgent: AgentTypeClaudeCode, + detected: true, + }, + { + name: "Claude Code via CLAUDE_CODE_ENTRYPOINT", + envVars: map[string]string{"CLAUDE_CODE_ENTRYPOINT": "/usr/bin/claude"}, + expectedAgent: AgentTypeClaudeCode, + detected: true, + }, + { + name: "GitHub Copilot CLI", + envVars: map[string]string{"GITHUB_COPILOT_CLI": "true"}, + expectedAgent: AgentTypeGitHubCopilotCLI, + detected: true, + }, + { + name: "Cursor", + envVars: map[string]string{"CURSOR_EDITOR": "1"}, + expectedAgent: AgentTypeCursor, + detected: true, + }, + { + name: "Windsurf", + envVars: map[string]string{"WINDSURF_EDITOR": "true"}, + expectedAgent: AgentTypeWindsurf, + detected: true, + }, + { + name: "Aider", + envVars: map[string]string{"AIDER_MODEL": "gpt-4"}, + expectedAgent: AgentTypeAider, + detected: true, + }, + { + name: "Amazon Q", + envVars: map[string]string{"AMAZON_Q_DEVELOPER": "1"}, + expectedAgent: AgentTypeAmazonQ, + detected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear any existing env vars that might interfere + clearAgentEnvVars(t) + + // Set test env vars + for k, v := range tt.envVars { + t.Setenv(k, v) + } + + result := detectFromEnvVars() + + assert.Equal(t, tt.detected, result.Detected) + assert.Equal(t, tt.expectedAgent, result.Type) + + if tt.detected { + assert.Equal(t, DetectionSourceEnvVar, result.Source) + assert.NotEmpty(t, result.Details) + } + }) + } +} + +func TestDetectFromUserAgent(t *testing.T) { + tests := []struct { + name string + userAgent string + expectedAgent AgentType + detected bool + }{ + { + name: "Empty user agent", + userAgent: "", + expectedAgent: AgentTypeUnknown, + detected: false, + }, + { + name: "Unrecognized user agent", + userAgent: "some-random-tool/1.0.0", + expectedAgent: AgentTypeUnknown, + detected: false, + }, + { + name: "Claude Code in user agent", + userAgent: "claude-code/1.2.3", + expectedAgent: AgentTypeClaudeCode, + detected: true, + }, + { + name: "GitHub Copilot in user agent", + userAgent: "github-copilot/2.0.0", + expectedAgent: AgentTypeGitHubCopilotCLI, + detected: true, + }, + { + name: "Cursor in user agent", + userAgent: "cursor/0.5.0", + expectedAgent: AgentTypeCursor, + detected: true, + }, + { + name: "VS Code Azure Copilot extension", + userAgent: internal.VsCodeAzureCopilotAgentPrefix + "/1.0.0", + expectedAgent: AgentTypeVSCodeCopilot, + detected: true, + }, + { + name: "Case insensitive matching", + userAgent: "CLAUDE-CODE/1.0.0", + expectedAgent: AgentTypeClaudeCode, + detected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(internal.AzdUserAgentEnvVar, tt.userAgent) + + result := detectFromUserAgent() + + assert.Equal(t, tt.detected, result.Detected) + assert.Equal(t, tt.expectedAgent, result.Type) + + if tt.detected { + assert.Equal(t, DetectionSourceUserAgent, result.Source) + assert.Equal(t, tt.userAgent, result.Details) + } + }) + } +} + +func TestMatchProcessToAgent(t *testing.T) { + tests := []struct { + name string + processInfo parentProcessInfo + expectedAgent AgentType + detected bool + }{ + { + name: "Empty process info", + processInfo: parentProcessInfo{}, + expectedAgent: AgentTypeUnknown, + detected: false, + }, + { + name: "Claude process name", + processInfo: parentProcessInfo{ + Name: "claude", + }, + expectedAgent: AgentTypeClaudeCode, + detected: true, + }, + { + name: "Claude Code process name", + processInfo: parentProcessInfo{ + Name: "claude-code", + }, + expectedAgent: AgentTypeClaudeCode, + detected: true, + }, + { + name: "Cursor executable on Windows", + processInfo: parentProcessInfo{ + Name: "Cursor.exe", + Executable: "C:\\Users\\test\\AppData\\Local\\Programs\\Cursor\\Cursor.exe", + }, + expectedAgent: AgentTypeCursor, + detected: true, + }, + { + name: "Cursor executable on macOS", + processInfo: parentProcessInfo{ + Name: "Cursor", + Executable: "/Applications/Cursor.app/Contents/MacOS/Cursor", + }, + expectedAgent: AgentTypeCursor, + detected: true, + }, + { + name: "GitHub Copilot CLI", + processInfo: parentProcessInfo{ + Name: "gh-copilot", + }, + expectedAgent: AgentTypeGitHubCopilotCLI, + detected: true, + }, + { + name: "Windsurf", + processInfo: parentProcessInfo{ + Name: "windsurf", + }, + expectedAgent: AgentTypeWindsurf, + detected: true, + }, + { + name: "Aider", + processInfo: parentProcessInfo{ + Name: "aider", + }, + expectedAgent: AgentTypeAider, + detected: true, + }, + { + name: "Unknown process", + processInfo: parentProcessInfo{ + Name: "bash", + Executable: "/bin/bash", + }, + expectedAgent: AgentTypeUnknown, + detected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := matchProcessToAgent(tt.processInfo) + + assert.Equal(t, tt.detected, result.Detected) + assert.Equal(t, tt.expectedAgent, result.Type) + + if tt.detected { + assert.Equal(t, DetectionSourceParentProcess, result.Source) + } + }) + } +} + +func TestGetCallingAgent_Caching(t *testing.T) { + clearAgentEnvVars(t) + ResetDetection() + + // First call - no agent + agent1 := GetCallingAgent() + require.False(t, agent1.Detected) + + // Set an env var - but cached result should be returned + t.Setenv("CLAUDE_CODE", "1") + agent2 := GetCallingAgent() + assert.False(t, agent2.Detected, "Should return cached result") + + // Reset and try again + ResetDetection() + agent3 := GetCallingAgent() + assert.True(t, agent3.Detected, "Should detect after reset") + assert.Equal(t, AgentTypeClaudeCode, agent3.Type) +} + +func TestIsRunningInAgent(t *testing.T) { + clearAgentEnvVars(t) + ResetDetection() + + assert.False(t, IsRunningInAgent()) + + t.Setenv("CURSOR_EDITOR", "1") + ResetDetection() + + assert.True(t, IsRunningInAgent()) +} + +// clearAgentEnvVars clears all environment variables that could trigger agent detection. +func clearAgentEnvVars(t *testing.T) { + envVarsToUnset := []string{ + "CLAUDE_CODE", "CLAUDE_CODE_ENTRYPOINT", + "GITHUB_COPILOT_CLI", "GH_COPILOT", + "OPENAI_CODEX", "CODEX_CLI", + "CURSOR_EDITOR", "CURSOR_SESSION_ID", + "WINDSURF_EDITOR", "WINDSURF_SESSION", + "AIDER_MODEL", "AIDER_CHAT_LANGUAGE", + "CONTINUE_GLOBAL_DIR", + "AMAZON_Q_DEVELOPER", "AWS_Q_DEVELOPER", "CODEWHISPERER_TOKEN", + internal.AzdUserAgentEnvVar, + } + + for _, envVar := range envVarsToUnset { + if _, exists := os.LookupEnv(envVar); exists { + t.Setenv(envVar, "") + os.Unsetenv(envVar) + } + } +} diff --git a/cli/azd/internal/runcontext/agentdetect/types.go b/cli/azd/internal/runcontext/agentdetect/types.go new file mode 100644 index 00000000000..1bdcd4f4562 --- /dev/null +++ b/cli/azd/internal/runcontext/agentdetect/types.go @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package agentdetect provides functionality to detect when azd is invoked +// by known AI coding agents (Claude Code, GitHub Copilot CLI, Cursor, etc.) +// and enables automatic adjustment of behavior (e.g., no-prompt mode). +package agentdetect + +// AgentType represents a known AI coding agent. +type AgentType string + +const ( + // AgentTypeUnknown indicates no agent was detected. + AgentTypeUnknown AgentType = "" + // AgentTypeClaudeCode is Anthropic's Claude Code agent. + AgentTypeClaudeCode AgentType = "claude-code" + // AgentTypeGitHubCopilotCLI is GitHub's Copilot CLI agent. + AgentTypeGitHubCopilotCLI AgentType = "github-copilot-cli" + // AgentTypeOpenAICodex is OpenAI's Codex CLI agent. + AgentTypeOpenAICodex AgentType = "openai-codex" + // AgentTypeCursor is the Cursor editor agent. + AgentTypeCursor AgentType = "cursor" + // AgentTypeWindsurf is the Windsurf editor agent (by Codeium). + AgentTypeWindsurf AgentType = "windsurf" + // AgentTypeAider is the Aider AI pair programming tool. + AgentTypeAider AgentType = "aider" + // AgentTypeContinue is the Continue coding assistant. + AgentTypeContinue AgentType = "continue" + // AgentTypeAmazonQ is Amazon Q Developer agent (formerly CodeWhisperer). + AgentTypeAmazonQ AgentType = "amazon-q" + // AgentTypeVSCodeCopilot is VS Code GitHub Copilot extension. + AgentTypeVSCodeCopilot AgentType = "vscode-copilot" + // AgentTypeCline is the Cline VS Code extension (formerly Claude Dev). + AgentTypeCline AgentType = "cline" + // AgentTypeZed is the Zed editor with AI features. + AgentTypeZed AgentType = "zed" + // AgentTypeTabnine is the Tabnine AI coding assistant. + AgentTypeTabnine AgentType = "tabnine" + // AgentTypeCody is Sourcegraph's Cody AI assistant. + AgentTypeCody AgentType = "cody" + // AgentTypeGemini is Google's Gemini CLI. + AgentTypeGemini AgentType = "gemini" + // AgentTypeGeneric indicates an agent was detected but not specifically identified. + AgentTypeGeneric AgentType = "generic" +) + +// String returns the string representation of the agent type. +func (a AgentType) String() string { + return string(a) +} + +// DisplayName returns a human-readable name for the agent type. +func (a AgentType) DisplayName() string { + switch a { + case AgentTypeClaudeCode: + return "Claude Code" + case AgentTypeGitHubCopilotCLI: + return "GitHub Copilot CLI" + case AgentTypeOpenAICodex: + return "OpenAI Codex" + case AgentTypeCursor: + return "Cursor" + case AgentTypeWindsurf: + return "Windsurf" + case AgentTypeAider: + return "Aider" + case AgentTypeContinue: + return "Continue" + case AgentTypeAmazonQ: + return "Amazon Q Developer" + case AgentTypeVSCodeCopilot: + return "VS Code GitHub Copilot" + case AgentTypeCline: + return "Cline" + case AgentTypeZed: + return "Zed" + case AgentTypeTabnine: + return "Tabnine" + case AgentTypeCody: + return "Cody" + case AgentTypeGemini: + return "Gemini" + case AgentTypeGeneric: + return "Generic Agent" + default: + return "Unknown" + } +} + +// DetectionSource indicates how an agent was detected. +type DetectionSource string + +const ( + // DetectionSourceNone indicates no detection occurred. + DetectionSourceNone DetectionSource = "" + // DetectionSourceEnvVar indicates detection via environment variable. + DetectionSourceEnvVar DetectionSource = "env-var" + // DetectionSourceParentProcess indicates detection via parent process inspection. + DetectionSourceParentProcess DetectionSource = "parent-process" + // DetectionSourceUserAgent indicates detection via AZURE_DEV_USER_AGENT. + DetectionSourceUserAgent DetectionSource = "user-agent" +) + +// AgentInfo contains information about a detected AI coding agent. +type AgentInfo struct { + // Type is the identified agent type. + Type AgentType + // Name is a human-readable name for the agent. + Name string + // Source indicates how the agent was detected. + Source DetectionSource + // Detected is true if an agent was detected. + Detected bool + // Details contains additional detection information (e.g., matched env var or process name). + Details string +} + +// NoAgent returns an AgentInfo indicating no agent was detected. +func NoAgent() AgentInfo { + return AgentInfo{ + Type: AgentTypeUnknown, + Name: "", + Source: DetectionSourceNone, + Detected: false, + } +} diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go index 513462729b7..7366be48ec4 100644 --- a/cli/azd/internal/tracing/fields/fields.go +++ b/cli/azd/internal/tracing/fields/fields.go @@ -276,6 +276,22 @@ const ( EnvVSCodeAzureCopilot = "VS Code Azure GitHub Copilot" EnvCloudShell = "Azure CloudShell" + // AI Coding Agent environments + EnvClaudeCode = "Claude Code" + EnvGitHubCopilotCLI = "GitHub Copilot CLI" + EnvOpenAICodex = "OpenAI Codex" + EnvCursor = "Cursor" + EnvWindsurf = "Windsurf" + EnvAider = "Aider" + EnvContinue = "Continue" + EnvAmazonQ = "Amazon Q Developer" + EnvCline = "Cline" + EnvZed = "Zed" + EnvTabnine = "Tabnine" + EnvCody = "Cody" + EnvGemini = "Gemini CLI" + EnvGenericAgent = "Generic Agent" + // Continuous Integration environments EnvUnknownCI = "UnknownCI" diff --git a/cli/azd/internal/tracing/resource/exec_environment.go b/cli/azd/internal/tracing/resource/exec_environment.go index e7aaaa07c63..515cc6c237d 100644 --- a/cli/azd/internal/tracing/resource/exec_environment.go +++ b/cli/azd/internal/tracing/resource/exec_environment.go @@ -9,6 +9,7 @@ import ( "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/runcontext" + "github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect" "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" ) @@ -17,6 +18,11 @@ func getExecutionEnvironment() string { // inner layers. env := execEnvFromCaller() + // Check for AI coding agents if no caller was detected via user agent + if env == "" { + env = execEnvFromAgent() + } + if env == "" { // machine-level execution environments env = execEnvForHosts() @@ -56,6 +62,50 @@ func execEnvFromCaller() string { return "" } +// execEnvFromAgent detects AI coding agents via the agentdetect package. +func execEnvFromAgent() string { + agent := agentdetect.GetCallingAgent() + if !agent.Detected { + return "" + } + + // Map agent types to telemetry environment values + switch agent.Type { + case agentdetect.AgentTypeClaudeCode: + return fields.EnvClaudeCode + case agentdetect.AgentTypeGitHubCopilotCLI: + return fields.EnvGitHubCopilotCLI + case agentdetect.AgentTypeOpenAICodex: + return fields.EnvOpenAICodex + case agentdetect.AgentTypeCursor: + return fields.EnvCursor + case agentdetect.AgentTypeWindsurf: + return fields.EnvWindsurf + case agentdetect.AgentTypeAider: + return fields.EnvAider + case agentdetect.AgentTypeContinue: + return fields.EnvContinue + case agentdetect.AgentTypeAmazonQ: + return fields.EnvAmazonQ + case agentdetect.AgentTypeVSCodeCopilot: + return fields.EnvVSCodeAzureCopilot + case agentdetect.AgentTypeCline: + return fields.EnvCline + case agentdetect.AgentTypeZed: + return fields.EnvZed + case agentdetect.AgentTypeTabnine: + return fields.EnvTabnine + case agentdetect.AgentTypeCody: + return fields.EnvCody + case agentdetect.AgentTypeGemini: + return fields.EnvGemini + case agentdetect.AgentTypeGeneric: + return fields.EnvGenericAgent + default: + return fields.EnvGenericAgent + } +} + func execEnvForHosts() string { if _, ok := os.LookupEnv(runcontext.AzdInCloudShellEnvVar); ok { return fields.EnvCloudShell From daa593d17adf0a6bbd2ea96a002c7303428ef1f7 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 28 Jan 2026 13:45:32 -0800 Subject: [PATCH 2/6] Addresses PR feedback --- cli/azd/.vscode/cspell.yaml | 15 +++++++ cli/azd/cmd/auto_install_integration_test.go | 45 +++++++++++++++++++ cli/azd/cmd/auto_install_test.go | 3 ++ .../runcontext/agentdetect/detect_env.go | 12 +++-- .../agentdetect/detect_process_windows.go | 2 +- .../runcontext/agentdetect/detect_test.go | 26 +++++++++-- 6 files changed, 92 insertions(+), 11 deletions(-) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index ee7c11f59d6..f43011aea80 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -1,5 +1,6 @@ import: ../../../.vscode/cspell.global.yaml words: + - agentdetect - azcloud - azdext - azurefd @@ -9,6 +10,8 @@ words: - Chans - chinacloudapi - cmds + - Codeium + - CODEWHISPERER - Codespace - Codespaces - cooldown @@ -21,22 +24,34 @@ words: - gfgac2cmf7b8cuay - goversioninfo - grpcbroker + - KIRO + - kiro - nosec - oneof - idxs # Looks like the protogen has a spelling error for panics - pancis + - Paren - pkgux + - ppid + - PPID - proto - protobuf - protoc - protoimpl - protojson - protoreflect + - SNAPPROCESS + - Sourcegraph + - sourcegraph - structpb - Retryable - runcontext - surveyterm + - Tabnine + - tabnine + - TABNINE + - Toolhelp - unmarshals - unmarshaling - unsetting diff --git a/cli/azd/cmd/auto_install_integration_test.go b/cli/azd/cmd/auto_install_integration_test.go index af7645c621b..dca4c90880c 100644 --- a/cli/azd/cmd/auto_install_integration_test.go +++ b/cli/azd/cmd/auto_install_integration_test.go @@ -147,6 +147,9 @@ func TestAgentDetectionIntegration(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Clear any ambient agent env vars to ensure test isolation + clearAgentEnvVarsForTest(t) + // Reset agent detection cache for each test agentdetect.ResetDetection() @@ -185,3 +188,45 @@ func containsNoPromptFalse(args []string) bool { } return false } + +// clearAgentEnvVarsForTest clears all environment variables that could trigger agent detection. +// This ensures tests are isolated from the ambient environment. +func clearAgentEnvVarsForTest(t *testing.T) { + envVarsToUnset := []string{ + // Claude Code + "CLAUDE_CODE", "CLAUDE_CODE_ENTRYPOINT", + // GitHub Copilot CLI + "GITHUB_COPILOT_CLI", "GH_COPILOT", + // OpenAI Codex + "OPENAI_CODEX", "CODEX_CLI", + // Cursor + "CURSOR_EDITOR", "CURSOR_SESSION_ID", "CURSOR_TRACE_ID", + // Windsurf + "WINDSURF_EDITOR", "WINDSURF_SESSION", + // Zed + "ZED_TERM", + // Aider + "AIDER_MODEL", "AIDER_CHAT_LANGUAGE", + // Continue + "CONTINUE_GLOBAL_DIR", "CONTINUE_DEVELOPMENT", + // Amazon Q + "AMAZON_Q_DEVELOPER", "AWS_Q_DEVELOPER", "KIRO_CLI", + // Cline + "CLINE_MCP", + // Tabnine + "TABNINE_CONFIG", + // Cody + "CODY_CONFIG", + // Gemini CLI + "GEMINI_CLI", "GEMINI_CLI_NO_RELAUNCH", "GEMINI_CODE_ASSIST", + // User agent + internal.AzdUserAgentEnvVar, + } + + for _, envVar := range envVarsToUnset { + if _, exists := os.LookupEnv(envVar); exists { + t.Setenv(envVar, "") + os.Unsetenv(envVar) + } + } +} diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index 7fed3183ff3..bcb576b8188 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -384,6 +384,9 @@ func TestParseGlobalFlags_AgentDetection(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + // Clear any ambient agent env vars to ensure test isolation + clearAgentEnvVarsForTest(t) + // Reset agent detection cache agentdetect.ResetDetection() diff --git a/cli/azd/internal/runcontext/agentdetect/detect_env.go b/cli/azd/internal/runcontext/agentdetect/detect_env.go index 7fef6c68cf6..ef2278732f6 100644 --- a/cli/azd/internal/runcontext/agentdetect/detect_env.go +++ b/cli/azd/internal/runcontext/agentdetect/detect_env.go @@ -49,7 +49,6 @@ var knownEnvVarPatterns = []envVarPattern{ // Aider - AI pair programming tool {envVar: "AIDER_MODEL", agentType: AgentTypeAider}, {envVar: "AIDER_CHAT_LANGUAGE", agentType: AgentTypeAider}, - {envVar: "AIDER_OPENAI_API_KEY", agentType: AgentTypeAider}, // Continue coding assistant {envVar: "CONTINUE_GLOBAL_DIR", agentType: AgentTypeContinue}, @@ -58,26 +57,25 @@ var knownEnvVarPatterns = []envVarPattern{ // Amazon Q Developer (formerly CodeWhisperer) {envVar: "AMAZON_Q_DEVELOPER", agentType: AgentTypeAmazonQ}, {envVar: "AWS_Q_DEVELOPER", agentType: AgentTypeAmazonQ}, - {envVar: "CODEWHISPERER_TOKEN", agentType: AgentTypeAmazonQ}, {envVar: "KIRO_CLI", agentType: AgentTypeAmazonQ}, // Cline (formerly Claude Dev) - VS Code extension - {envVar: "CLINE_API_KEY", agentType: AgentTypeCline}, + // Note: CLINE_API_KEY is too generic, only detect when MCP integration is active {envVar: "CLINE_MCP", agentType: AgentTypeCline}, // Tabnine - AI code completion - {envVar: "TABNINE_TOKEN", agentType: AgentTypeTabnine}, + // Note: TABNINE_TOKEN is too generic, only detect config which indicates active session {envVar: "TABNINE_CONFIG", agentType: AgentTypeTabnine}, // Cody (Sourcegraph) - AI coding assistant - {envVar: "SRC_ACCESS_TOKEN", agentType: AgentTypeCody}, + // Note: SRC_ACCESS_TOKEN is too generic (used by Sourcegraph CLI), only detect Cody-specific vars {envVar: "CODY_CONFIG", agentType: AgentTypeCody}, // Google Gemini CLI + // Note: GEMINI_API_KEY and GOOGLE_GEMINI_API_KEY are too generic (used by SDK/CLI), + // only detect Gemini CLI-specific vars that indicate the CLI is running {envVar: "GEMINI_CLI", agentType: AgentTypeGemini}, {envVar: "GEMINI_CLI_NO_RELAUNCH", agentType: AgentTypeGemini}, - {envVar: "GEMINI_API_KEY", agentType: AgentTypeGemini}, - {envVar: "GOOGLE_GEMINI_API_KEY", agentType: AgentTypeGemini}, {envVar: "GEMINI_CODE_ASSIST", agentType: AgentTypeGemini}, } diff --git a/cli/azd/internal/runcontext/agentdetect/detect_process_windows.go b/cli/azd/internal/runcontext/agentdetect/detect_process_windows.go index 2f5fcca9e9a..1d096e684e8 100644 --- a/cli/azd/internal/runcontext/agentdetect/detect_process_windows.go +++ b/cli/azd/internal/runcontext/agentdetect/detect_process_windows.go @@ -37,7 +37,7 @@ func getParentProcessInfoWithPPID(pid int) (parentProcessInfo, int, error) { info.Name = getBaseName(exePath) } - // Get parent PID using NtQueryInformationProcess + // Get parent PID using Toolhelp32 process snapshot enumeration parentPid, err = getParentPid(pid) if err != nil { // Non-fatal - we still have the process info diff --git a/cli/azd/internal/runcontext/agentdetect/detect_test.go b/cli/azd/internal/runcontext/agentdetect/detect_test.go index a5cb18f3aa1..d7d61b7e3a4 100644 --- a/cli/azd/internal/runcontext/agentdetect/detect_test.go +++ b/cli/azd/internal/runcontext/agentdetect/detect_test.go @@ -326,16 +326,36 @@ func TestIsRunningInAgent(t *testing.T) { } // clearAgentEnvVars clears all environment variables that could trigger agent detection. +// This list must be kept in sync with knownEnvVarPatterns in detect_env.go. func clearAgentEnvVars(t *testing.T) { envVarsToUnset := []string{ + // Claude Code "CLAUDE_CODE", "CLAUDE_CODE_ENTRYPOINT", + // GitHub Copilot CLI "GITHUB_COPILOT_CLI", "GH_COPILOT", + // OpenAI Codex "OPENAI_CODEX", "CODEX_CLI", - "CURSOR_EDITOR", "CURSOR_SESSION_ID", + // Cursor + "CURSOR_EDITOR", "CURSOR_SESSION_ID", "CURSOR_TRACE_ID", + // Windsurf "WINDSURF_EDITOR", "WINDSURF_SESSION", + // Zed + "ZED_TERM", + // Aider "AIDER_MODEL", "AIDER_CHAT_LANGUAGE", - "CONTINUE_GLOBAL_DIR", - "AMAZON_Q_DEVELOPER", "AWS_Q_DEVELOPER", "CODEWHISPERER_TOKEN", + // Continue + "CONTINUE_GLOBAL_DIR", "CONTINUE_DEVELOPMENT", + // Amazon Q + "AMAZON_Q_DEVELOPER", "AWS_Q_DEVELOPER", "KIRO_CLI", + // Cline + "CLINE_MCP", + // Tabnine + "TABNINE_CONFIG", + // Cody + "CODY_CONFIG", + // Gemini CLI + "GEMINI_CLI", "GEMINI_CLI_NO_RELAUNCH", "GEMINI_CODE_ASSIST", + // User agent internal.AzdUserAgentEnvVar, } From 15dea2d8d78ff85326496686da1c81d934c4cc20 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Wed, 28 Jan 2026 16:00:35 -0800 Subject: [PATCH 3/6] Fixes unit test issue and adds support for opencode --- cli/azd/.vscode/cspell.yaml | 2 ++ cli/azd/cmd/auto_install.go | 3 +-- cli/azd/cmd/auto_install_integration_test.go | 2 ++ cli/azd/internal/global_command_options.go | 4 +--- cli/azd/internal/runcontext/agentdetect/detect_env.go | 4 ++++ cli/azd/internal/runcontext/agentdetect/detect_process.go | 5 +++++ cli/azd/internal/runcontext/agentdetect/detect_test.go | 2 ++ cli/azd/internal/runcontext/agentdetect/types.go | 4 ++++ cli/azd/internal/tracing/fields/fields.go | 1 + cli/azd/internal/tracing/resource/exec_environment.go | 2 ++ 10 files changed, 24 insertions(+), 5 deletions(-) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index f43011aea80..028bdf85101 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -23,6 +23,8 @@ words: # CDN host name - gfgac2cmf7b8cuay - goversioninfo + - OPENCODE + - opencode - grpcbroker - KIRO - kiro diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 3d005016016..33415e7bbfd 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -492,8 +492,7 @@ func CreateGlobalFlagSet() *pflag.FlagSet { globalFlags.Bool( "no-prompt", false, - "Accepts the default value instead of prompting, or it fails if there is no default. "+ - "Automatically enabled when running inside AI coding agents (Claude Code, Cursor, GitHub Copilot CLI, etc.).") + "Accepts the default value instead of prompting, or it fails if there is no default.") // The telemetry system is responsible for reading these flags value and using it to configure the telemetry // system, but we still need to add it to our flag set so that when we parse the command line with Cobra we diff --git a/cli/azd/cmd/auto_install_integration_test.go b/cli/azd/cmd/auto_install_integration_test.go index dca4c90880c..c87f090f269 100644 --- a/cli/azd/cmd/auto_install_integration_test.go +++ b/cli/azd/cmd/auto_install_integration_test.go @@ -219,6 +219,8 @@ func clearAgentEnvVarsForTest(t *testing.T) { "CODY_CONFIG", // Gemini CLI "GEMINI_CLI", "GEMINI_CLI_NO_RELAUNCH", "GEMINI_CODE_ASSIST", + // OpenCode + "OPENCODE", // User agent internal.AzdUserAgentEnvVar, } diff --git a/cli/azd/internal/global_command_options.go b/cli/azd/internal/global_command_options.go index 81d15c5b2c5..742fe421490 100644 --- a/cli/azd/internal/global_command_options.go +++ b/cli/azd/internal/global_command_options.go @@ -14,10 +14,8 @@ type GlobalCommandOptions struct { // launched tools. It's enabled with `--debug`, for any command. EnableDebugLogging bool - // NoPrompt when true, interactive prompts should behave as if the user selected the default value. + // when true, interactive prompts should behave as if the user selected the default value. // if there is no default value the prompt returns an error. - // This is automatically set to true when an AI coding agent (Claude Code, Cursor, GitHub Copilot CLI, - // Windsurf, Aider, etc.) is detected as the caller, unless explicitly set to false with --no-prompt=false. NoPrompt bool // EnableTelemetry indicates if telemetry should be sent. diff --git a/cli/azd/internal/runcontext/agentdetect/detect_env.go b/cli/azd/internal/runcontext/agentdetect/detect_env.go index ef2278732f6..1cdc299918a 100644 --- a/cli/azd/internal/runcontext/agentdetect/detect_env.go +++ b/cli/azd/internal/runcontext/agentdetect/detect_env.go @@ -77,6 +77,9 @@ var knownEnvVarPatterns = []envVarPattern{ {envVar: "GEMINI_CLI", agentType: AgentTypeGemini}, {envVar: "GEMINI_CLI_NO_RELAUNCH", agentType: AgentTypeGemini}, {envVar: "GEMINI_CODE_ASSIST", agentType: AgentTypeGemini}, + + // OpenCode - AI coding CLI + {envVar: "OPENCODE", agentType: AgentTypeOpenCode}, } // detectFromEnvVars checks for known AI agent environment variables. @@ -124,6 +127,7 @@ var userAgentPatterns = []struct { {substring: "cody", agentType: AgentTypeCody}, {substring: "sourcegraph", agentType: AgentTypeCody}, {substring: "gemini", agentType: AgentTypeGemini}, + {substring: "opencode", agentType: AgentTypeOpenCode}, {substring: "codex", agentType: AgentTypeOpenAICodex}, {substring: "continue", agentType: AgentTypeContinue}, } diff --git a/cli/azd/internal/runcontext/agentdetect/detect_process.go b/cli/azd/internal/runcontext/agentdetect/detect_process.go index 85a46b69d94..e3b0f43cc1b 100644 --- a/cli/azd/internal/runcontext/agentdetect/detect_process.go +++ b/cli/azd/internal/runcontext/agentdetect/detect_process.go @@ -80,6 +80,11 @@ var processNamePatterns = []struct { patterns: []string{"gemini", "gemini-code", "google-gemini"}, agentType: AgentTypeGemini, }, + // OpenCode - AI coding CLI + { + patterns: []string{"opencode"}, + agentType: AgentTypeOpenCode, + }, } // maxProcessTreeDepth limits how far up the process tree we walk to prevent infinite loops. diff --git a/cli/azd/internal/runcontext/agentdetect/detect_test.go b/cli/azd/internal/runcontext/agentdetect/detect_test.go index d7d61b7e3a4..2f6004acef3 100644 --- a/cli/azd/internal/runcontext/agentdetect/detect_test.go +++ b/cli/azd/internal/runcontext/agentdetect/detect_test.go @@ -355,6 +355,8 @@ func clearAgentEnvVars(t *testing.T) { "CODY_CONFIG", // Gemini CLI "GEMINI_CLI", "GEMINI_CLI_NO_RELAUNCH", "GEMINI_CODE_ASSIST", + // OpenCode + "OPENCODE", // User agent internal.AzdUserAgentEnvVar, } diff --git a/cli/azd/internal/runcontext/agentdetect/types.go b/cli/azd/internal/runcontext/agentdetect/types.go index 1bdcd4f4562..9b54266b228 100644 --- a/cli/azd/internal/runcontext/agentdetect/types.go +++ b/cli/azd/internal/runcontext/agentdetect/types.go @@ -40,6 +40,8 @@ const ( AgentTypeCody AgentType = "cody" // AgentTypeGemini is Google's Gemini CLI. AgentTypeGemini AgentType = "gemini" + // AgentTypeOpenCode is the OpenCode AI coding CLI. + AgentTypeOpenCode AgentType = "opencode" // AgentTypeGeneric indicates an agent was detected but not specifically identified. AgentTypeGeneric AgentType = "generic" ) @@ -80,6 +82,8 @@ func (a AgentType) DisplayName() string { return "Cody" case AgentTypeGemini: return "Gemini" + case AgentTypeOpenCode: + return "OpenCode" case AgentTypeGeneric: return "Generic Agent" default: diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go index 7366be48ec4..e7bfeb182af 100644 --- a/cli/azd/internal/tracing/fields/fields.go +++ b/cli/azd/internal/tracing/fields/fields.go @@ -290,6 +290,7 @@ const ( EnvTabnine = "Tabnine" EnvCody = "Cody" EnvGemini = "Gemini CLI" + EnvOpenCode = "OpenCode" EnvGenericAgent = "Generic Agent" // Continuous Integration environments diff --git a/cli/azd/internal/tracing/resource/exec_environment.go b/cli/azd/internal/tracing/resource/exec_environment.go index 515cc6c237d..1a4eb9846bb 100644 --- a/cli/azd/internal/tracing/resource/exec_environment.go +++ b/cli/azd/internal/tracing/resource/exec_environment.go @@ -99,6 +99,8 @@ func execEnvFromAgent() string { return fields.EnvCody case agentdetect.AgentTypeGemini: return fields.EnvGemini + case agentdetect.AgentTypeOpenCode: + return fields.EnvOpenCode case agentdetect.AgentTypeGeneric: return fields.EnvGenericAgent default: From 2d35b4aa40eef1d512629ad1718310a2fea1d94a Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 29 Jan 2026 15:38:03 -0800 Subject: [PATCH 4/6] Updates supported agents and updates TTY usage. --- cli/azd/cmd/auto_install_integration_test.go | 41 +----- cli/azd/cmd/auto_install_test.go | 12 +- .../runcontext/agentdetect/detect_env.go | 68 +--------- .../runcontext/agentdetect/detect_process.go | 127 +++++++----------- .../runcontext/agentdetect/detect_test.go | 113 +++++----------- .../internal/runcontext/agentdetect/types.go | 46 +------ cli/azd/internal/terminal/terminal.go | 6 + cli/azd/internal/terminal/terminal_test.go | 104 ++++++++++++++ cli/azd/internal/tracing/fields/fields.go | 13 +- .../tracing/resource/exec_environment.go | 24 +--- 10 files changed, 216 insertions(+), 338 deletions(-) create mode 100644 cli/azd/internal/terminal/terminal_test.go diff --git a/cli/azd/cmd/auto_install_integration_test.go b/cli/azd/cmd/auto_install_integration_test.go index c87f090f269..31a7e3730d3 100644 --- a/cli/azd/cmd/auto_install_integration_test.go +++ b/cli/azd/cmd/auto_install_integration_test.go @@ -92,13 +92,6 @@ func TestAgentDetectionIntegration(t *testing.T) { expectedNoPrompt: true, description: "When running under Claude Code, --no-prompt should be auto-enabled", }, - { - name: "Cursor agent enables no-prompt automatically", - args: []string{"up"}, - envVars: map[string]string{"CURSOR_EDITOR": "1"}, - expectedNoPrompt: true, - description: "When running under Cursor, --no-prompt should be auto-enabled", - }, { name: "GitHub Copilot CLI enables no-prompt automatically", args: []string{"deploy"}, @@ -107,18 +100,18 @@ func TestAgentDetectionIntegration(t *testing.T) { description: "When running under GitHub Copilot CLI, --no-prompt should be auto-enabled", }, { - name: "Windsurf agent enables no-prompt automatically", + name: "Gemini agent enables no-prompt automatically", args: []string{"init"}, - envVars: map[string]string{"WINDSURF_EDITOR": "1"}, + envVars: map[string]string{"GEMINI_CLI": "1"}, expectedNoPrompt: true, - description: "When running under Windsurf, --no-prompt should be auto-enabled", + description: "When running under Gemini, --no-prompt should be auto-enabled", }, { - name: "Aider agent enables no-prompt automatically", + name: "OpenCode agent enables no-prompt automatically", args: []string{"provision"}, - envVars: map[string]string{"AIDER_MODEL": "gpt-4"}, + envVars: map[string]string{"OPENCODE": "1"}, expectedNoPrompt: true, - description: "When running under Aider, --no-prompt should be auto-enabled", + description: "When running under OpenCode, --no-prompt should be auto-enabled", }, { name: "User can override agent detection with --no-prompt=false", @@ -197,28 +190,8 @@ func clearAgentEnvVarsForTest(t *testing.T) { "CLAUDE_CODE", "CLAUDE_CODE_ENTRYPOINT", // GitHub Copilot CLI "GITHUB_COPILOT_CLI", "GH_COPILOT", - // OpenAI Codex - "OPENAI_CODEX", "CODEX_CLI", - // Cursor - "CURSOR_EDITOR", "CURSOR_SESSION_ID", "CURSOR_TRACE_ID", - // Windsurf - "WINDSURF_EDITOR", "WINDSURF_SESSION", - // Zed - "ZED_TERM", - // Aider - "AIDER_MODEL", "AIDER_CHAT_LANGUAGE", - // Continue - "CONTINUE_GLOBAL_DIR", "CONTINUE_DEVELOPMENT", - // Amazon Q - "AMAZON_Q_DEVELOPER", "AWS_Q_DEVELOPER", "KIRO_CLI", - // Cline - "CLINE_MCP", - // Tabnine - "TABNINE_CONFIG", - // Cody - "CODY_CONFIG", // Gemini CLI - "GEMINI_CLI", "GEMINI_CLI_NO_RELAUNCH", "GEMINI_CODE_ASSIST", + "GEMINI_CLI", "GEMINI_CLI_NO_RELAUNCH", // OpenCode "OPENCODE", // User agent diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index bcb576b8188..20b8cb9fb73 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -359,7 +359,7 @@ func TestParseGlobalFlags_AgentDetection(t *testing.T) { { name: "agent detected but --no-prompt explicitly set true", args: []string{"--no-prompt", "up"}, - envVars: map[string]string{"CURSOR_EDITOR": "1"}, + envVars: map[string]string{"GEMINI_CLI": "1"}, expectedNoPrompt: true, }, { @@ -369,9 +369,9 @@ func TestParseGlobalFlags_AgentDetection(t *testing.T) { expectedNoPrompt: true, }, { - name: "Cursor agent detected", + name: "Gemini agent detected", args: []string{"init"}, - envVars: map[string]string{"CURSOR_EDITOR": "1"}, + envVars: map[string]string{"GEMINI_CLI": "1"}, expectedNoPrompt: true, }, { @@ -380,6 +380,12 @@ func TestParseGlobalFlags_AgentDetection(t *testing.T) { envVars: map[string]string{"GITHUB_COPILOT_CLI": "true"}, expectedNoPrompt: true, }, + { + name: "OpenCode agent detected", + args: []string{"provision"}, + envVars: map[string]string{"OPENCODE": "1"}, + expectedNoPrompt: true, + }, } for _, tt := range tests { diff --git a/cli/azd/internal/runcontext/agentdetect/detect_env.go b/cli/azd/internal/runcontext/agentdetect/detect_env.go index 1cdc299918a..45331f0cb78 100644 --- a/cli/azd/internal/runcontext/agentdetect/detect_env.go +++ b/cli/azd/internal/runcontext/agentdetect/detect_env.go @@ -10,13 +10,10 @@ import ( "github.com/azure/azure-dev/cli/azd/internal" ) -// agentEnvVarPatterns maps environment variables to agent types. -// Each entry defines env vars that indicate a specific agent is running. +// envVarPattern maps environment variables to agent types. type envVarPattern struct { envVar string agentType AgentType - // checkValue optionally validates the env var value (if empty, presence is enough) - checkValue string } // knownEnvVarPatterns defines environment variables that indicate known AI agents. @@ -30,53 +27,9 @@ var knownEnvVarPatterns = []envVarPattern{ {envVar: "GITHUB_COPILOT_CLI", agentType: AgentTypeGitHubCopilotCLI}, {envVar: "GH_COPILOT", agentType: AgentTypeGitHubCopilotCLI}, - // OpenAI Codex CLI - {envVar: "OPENAI_CODEX", agentType: AgentTypeOpenAICodex}, - {envVar: "CODEX_CLI", agentType: AgentTypeOpenAICodex}, - - // Cursor editor - VS Code fork with AI - {envVar: "CURSOR_EDITOR", agentType: AgentTypeCursor}, - {envVar: "CURSOR_SESSION_ID", agentType: AgentTypeCursor}, - {envVar: "CURSOR_TRACE_ID", agentType: AgentTypeCursor}, - - // Windsurf editor (by Codeium) - VS Code fork - {envVar: "WINDSURF_EDITOR", agentType: AgentTypeWindsurf}, - {envVar: "WINDSURF_SESSION", agentType: AgentTypeWindsurf}, - - // Zed editor - Rust-based editor with AI - {envVar: "ZED_TERM", agentType: AgentTypeZed}, - - // Aider - AI pair programming tool - {envVar: "AIDER_MODEL", agentType: AgentTypeAider}, - {envVar: "AIDER_CHAT_LANGUAGE", agentType: AgentTypeAider}, - - // Continue coding assistant - {envVar: "CONTINUE_GLOBAL_DIR", agentType: AgentTypeContinue}, - {envVar: "CONTINUE_DEVELOPMENT", agentType: AgentTypeContinue}, - - // Amazon Q Developer (formerly CodeWhisperer) - {envVar: "AMAZON_Q_DEVELOPER", agentType: AgentTypeAmazonQ}, - {envVar: "AWS_Q_DEVELOPER", agentType: AgentTypeAmazonQ}, - {envVar: "KIRO_CLI", agentType: AgentTypeAmazonQ}, - - // Cline (formerly Claude Dev) - VS Code extension - // Note: CLINE_API_KEY is too generic, only detect when MCP integration is active - {envVar: "CLINE_MCP", agentType: AgentTypeCline}, - - // Tabnine - AI code completion - // Note: TABNINE_TOKEN is too generic, only detect config which indicates active session - {envVar: "TABNINE_CONFIG", agentType: AgentTypeTabnine}, - - // Cody (Sourcegraph) - AI coding assistant - // Note: SRC_ACCESS_TOKEN is too generic (used by Sourcegraph CLI), only detect Cody-specific vars - {envVar: "CODY_CONFIG", agentType: AgentTypeCody}, - // Google Gemini CLI - // Note: GEMINI_API_KEY and GOOGLE_GEMINI_API_KEY are too generic (used by SDK/CLI), - // only detect Gemini CLI-specific vars that indicate the CLI is running {envVar: "GEMINI_CLI", agentType: AgentTypeGemini}, {envVar: "GEMINI_CLI_NO_RELAUNCH", agentType: AgentTypeGemini}, - {envVar: "GEMINI_CODE_ASSIST", agentType: AgentTypeGemini}, // OpenCode - AI coding CLI {envVar: "OPENCODE", agentType: AgentTypeOpenCode}, @@ -85,12 +38,7 @@ var knownEnvVarPatterns = []envVarPattern{ // detectFromEnvVars checks for known AI agent environment variables. func detectFromEnvVars() AgentInfo { for _, pattern := range knownEnvVarPatterns { - if value, exists := os.LookupEnv(pattern.envVar); exists { - // If checkValue is specified, verify it matches - if pattern.checkValue != "" && value != pattern.checkValue { - continue - } - + if _, exists := os.LookupEnv(pattern.envVar); exists { return AgentInfo{ Type: pattern.agentType, Name: pattern.agentType.DisplayName(), @@ -116,20 +64,8 @@ var userAgentPatterns = []struct { {substring: "copilot-cli", agentType: AgentTypeGitHubCopilotCLI}, {substring: "claude-code", agentType: AgentTypeClaudeCode}, {substring: "claude", agentType: AgentTypeClaudeCode}, - {substring: "cursor", agentType: AgentTypeCursor}, - {substring: "windsurf", agentType: AgentTypeWindsurf}, - {substring: "aider", agentType: AgentTypeAider}, - {substring: "amazon-q", agentType: AgentTypeAmazonQ}, - {substring: "kiro", agentType: AgentTypeAmazonQ}, - {substring: "cline", agentType: AgentTypeCline}, - {substring: "zed", agentType: AgentTypeZed}, - {substring: "tabnine", agentType: AgentTypeTabnine}, - {substring: "cody", agentType: AgentTypeCody}, - {substring: "sourcegraph", agentType: AgentTypeCody}, {substring: "gemini", agentType: AgentTypeGemini}, {substring: "opencode", agentType: AgentTypeOpenCode}, - {substring: "codex", agentType: AgentTypeOpenAICodex}, - {substring: "continue", agentType: AgentTypeContinue}, } // detectFromUserAgent checks the AZURE_DEV_USER_AGENT env var for known agents. diff --git a/cli/azd/internal/runcontext/agentdetect/detect_process.go b/cli/azd/internal/runcontext/agentdetect/detect_process.go index e3b0f43cc1b..a5f8ba4e5b5 100644 --- a/cli/azd/internal/runcontext/agentdetect/detect_process.go +++ b/cli/azd/internal/runcontext/agentdetect/detect_process.go @@ -4,6 +4,7 @@ package agentdetect import ( + "log" "os" "path/filepath" "strings" @@ -25,56 +26,6 @@ var processNamePatterns = []struct { patterns: []string{"copilot", "copilot-cli", "gh-copilot", "github-copilot", "github-copilot-cli"}, agentType: AgentTypeGitHubCopilotCLI, }, - // OpenAI Codex CLI - Rust-based CLI - { - patterns: []string{"codex", "openai-codex"}, - agentType: AgentTypeOpenAICodex, - }, - // Cursor Editor - VS Code fork with AI - { - patterns: []string{"cursor"}, - agentType: AgentTypeCursor, - }, - // Windsurf Editor (by Codeium) - VS Code fork - { - patterns: []string{"windsurf"}, - agentType: AgentTypeWindsurf, - }, - // Aider - AI pair programming tool (Python-based, may appear as python with aider in args) - { - patterns: []string{"aider", "aider-chat"}, - agentType: AgentTypeAider, - }, - // Continue - AI coding assistant (CLI is 'cn' command) - { - patterns: []string{"continue", "cn"}, - agentType: AgentTypeContinue, - }, - // Amazon Q Developer (formerly CodeWhisperer) - CLI is 'q', also 'kiro' for new version - { - patterns: []string{"amazon-q", "q-developer", "chat_cli", "kiro"}, - agentType: AgentTypeAmazonQ, - }, - // Cline (formerly Claude Dev) - VS Code extension, may have CLI - { - patterns: []string{"cline", "claude-dev"}, - agentType: AgentTypeCline, - }, - // Zed Editor - Rust-based editor with AI features - { - patterns: []string{"zed"}, - agentType: AgentTypeZed, - }, - // Tabnine - AI code completion - { - patterns: []string{"tabnine", "tabnine-companion"}, - agentType: AgentTypeTabnine, - }, - // Cody (Sourcegraph) - AI coding assistant - { - patterns: []string{"cody", "sourcegraph"}, - agentType: AgentTypeCody, - }, // Google Gemini CLI { patterns: []string{"gemini", "gemini-code", "google-gemini"}, @@ -98,60 +49,82 @@ func detectFromParentProcess() AgentInfo { for depth := 0; depth < maxProcessTreeDepth && currentPid > 1; depth++ { info, parentPid, err := getParentProcessInfoWithPPID(currentPid) if err != nil { + log.Printf("detect_process.go: Failed to get process info for pid %d: %v", currentPid, err) break } - // Check if this process matches a known agent - agent := matchProcessToAgent(info) - if agent.Detected { - return agent + log.Printf("detect_process.go: Parent process detection: depth=%d, pid=%d, ppid=%d, name=%q, executable=%q", + depth, currentPid, parentPid, info.Name, info.Executable) + + // Try to match this process against known agents + result := matchProcessToAgent(info) + if result.Detected { + return result } - // Move up the tree + // Move up to the parent if parentPid <= 1 || parentPid == currentPid { - // Reached root or stuck in a loop break } currentPid = parentPid } + log.Printf("detect_process.go: Parent process detection: no agent found in process tree") return NoAgent() } -// parentProcessInfo contains information about the parent process. +// parentProcessInfo contains information about a parent process. type parentProcessInfo struct { - // Name is the process name (e.g., "claude" or "cursor.exe") - Name string - // Executable is the full path to the executable (if available) + Name string Executable string - // CommandLine is the full command line (if available) - CommandLine string } -// matchProcessToAgent matches process info against known agent patterns. +// matchProcessToAgent checks if a process matches any known AI agent patterns. func matchProcessToAgent(info parentProcessInfo) AgentInfo { - // Normalize for matching + if info.Name == "" && info.Executable == "" { + return NoAgent() + } + nameLower := strings.ToLower(info.Name) - exeLower := strings.ToLower(filepath.Base(info.Executable)) + execLower := strings.ToLower(info.Executable) + execBaseLower := strings.ToLower(filepath.Base(info.Executable)) - // Remove common extensions for matching + // Remove common executable extensions for matching nameLower = strings.TrimSuffix(nameLower, ".exe") - exeLower = strings.TrimSuffix(exeLower, ".exe") - - for _, pattern := range processNamePatterns { - for _, p := range pattern.patterns { - if strings.Contains(nameLower, p) || strings.Contains(exeLower, p) { - matchedOn := info.Name - if info.Executable != "" { - matchedOn = info.Executable + execBaseLower = strings.TrimSuffix(execBaseLower, ".exe") + + for _, entry := range processNamePatterns { + for _, pattern := range entry.patterns { + // Check against process name + if nameLower == pattern || strings.Contains(nameLower, pattern) { + return AgentInfo{ + Type: entry.agentType, + Name: entry.agentType.DisplayName(), + Source: DetectionSourceParentProcess, + Detected: true, + Details: info.Name, + } + } + + // Check against executable base name + if execBaseLower == pattern || strings.Contains(execBaseLower, pattern) { + return AgentInfo{ + Type: entry.agentType, + Name: entry.agentType.DisplayName(), + Source: DetectionSourceParentProcess, + Detected: true, + Details: info.Executable, } + } + // Check if pattern appears in full executable path (for detection via install paths) + if strings.Contains(execLower, pattern) { return AgentInfo{ - Type: pattern.agentType, - Name: pattern.agentType.DisplayName(), + Type: entry.agentType, + Name: entry.agentType.DisplayName(), Source: DetectionSourceParentProcess, Detected: true, - Details: matchedOn, + Details: info.Executable, } } } diff --git a/cli/azd/internal/runcontext/agentdetect/detect_test.go b/cli/azd/internal/runcontext/agentdetect/detect_test.go index 2f6004acef3..b3c5051e441 100644 --- a/cli/azd/internal/runcontext/agentdetect/detect_test.go +++ b/cli/azd/internal/runcontext/agentdetect/detect_test.go @@ -19,18 +19,9 @@ func TestAgentType_DisplayName(t *testing.T) { }{ {AgentTypeClaudeCode, "Claude Code"}, {AgentTypeGitHubCopilotCLI, "GitHub Copilot CLI"}, - {AgentTypeOpenAICodex, "OpenAI Codex"}, - {AgentTypeCursor, "Cursor"}, - {AgentTypeWindsurf, "Windsurf"}, - {AgentTypeAider, "Aider"}, - {AgentTypeContinue, "Continue"}, - {AgentTypeAmazonQ, "Amazon Q Developer"}, {AgentTypeVSCodeCopilot, "VS Code GitHub Copilot"}, - {AgentTypeCline, "Cline"}, - {AgentTypeZed, "Zed"}, - {AgentTypeTabnine, "Tabnine"}, - {AgentTypeCody, "Cody"}, - {AgentTypeGeneric, "Generic Agent"}, + {AgentTypeGemini, "Gemini"}, + {AgentTypeOpenCode, "OpenCode"}, {AgentTypeUnknown, "Unknown"}, } @@ -75,43 +66,41 @@ func TestDetectFromEnvVars(t *testing.T) { detected: true, }, { - name: "GitHub Copilot CLI", + name: "GitHub Copilot CLI via GITHUB_COPILOT_CLI", envVars: map[string]string{"GITHUB_COPILOT_CLI": "true"}, expectedAgent: AgentTypeGitHubCopilotCLI, detected: true, }, { - name: "Cursor", - envVars: map[string]string{"CURSOR_EDITOR": "1"}, - expectedAgent: AgentTypeCursor, + name: "GitHub Copilot CLI via GH_COPILOT", + envVars: map[string]string{"GH_COPILOT": "1"}, + expectedAgent: AgentTypeGitHubCopilotCLI, detected: true, }, { - name: "Windsurf", - envVars: map[string]string{"WINDSURF_EDITOR": "true"}, - expectedAgent: AgentTypeWindsurf, + name: "Gemini CLI via GEMINI_CLI", + envVars: map[string]string{"GEMINI_CLI": "1"}, + expectedAgent: AgentTypeGemini, detected: true, }, { - name: "Aider", - envVars: map[string]string{"AIDER_MODEL": "gpt-4"}, - expectedAgent: AgentTypeAider, + name: "Gemini CLI via GEMINI_CLI_NO_RELAUNCH", + envVars: map[string]string{"GEMINI_CLI_NO_RELAUNCH": "1"}, + expectedAgent: AgentTypeGemini, detected: true, }, { - name: "Amazon Q", - envVars: map[string]string{"AMAZON_Q_DEVELOPER": "1"}, - expectedAgent: AgentTypeAmazonQ, + name: "OpenCode via OPENCODE", + envVars: map[string]string{"OPENCODE": "1"}, + expectedAgent: AgentTypeOpenCode, detected: true, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Clear any existing env vars that might interfere clearAgentEnvVars(t) - // Set test env vars for k, v := range tt.envVars { t.Setenv(k, v) } @@ -160,18 +149,24 @@ func TestDetectFromUserAgent(t *testing.T) { expectedAgent: AgentTypeGitHubCopilotCLI, detected: true, }, - { - name: "Cursor in user agent", - userAgent: "cursor/0.5.0", - expectedAgent: AgentTypeCursor, - detected: true, - }, { name: "VS Code Azure Copilot extension", userAgent: internal.VsCodeAzureCopilotAgentPrefix + "/1.0.0", expectedAgent: AgentTypeVSCodeCopilot, detected: true, }, + { + name: "Gemini in user agent", + userAgent: "gemini-cli/1.0.0", + expectedAgent: AgentTypeGemini, + detected: true, + }, + { + name: "OpenCode in user agent", + userAgent: "opencode/0.1.0", + expectedAgent: AgentTypeOpenCode, + detected: true, + }, { name: "Case insensitive matching", userAgent: "CLAUDE-CODE/1.0.0", @@ -226,24 +221,6 @@ func TestMatchProcessToAgent(t *testing.T) { expectedAgent: AgentTypeClaudeCode, detected: true, }, - { - name: "Cursor executable on Windows", - processInfo: parentProcessInfo{ - Name: "Cursor.exe", - Executable: "C:\\Users\\test\\AppData\\Local\\Programs\\Cursor\\Cursor.exe", - }, - expectedAgent: AgentTypeCursor, - detected: true, - }, - { - name: "Cursor executable on macOS", - processInfo: parentProcessInfo{ - Name: "Cursor", - Executable: "/Applications/Cursor.app/Contents/MacOS/Cursor", - }, - expectedAgent: AgentTypeCursor, - detected: true, - }, { name: "GitHub Copilot CLI", processInfo: parentProcessInfo{ @@ -253,19 +230,19 @@ func TestMatchProcessToAgent(t *testing.T) { detected: true, }, { - name: "Windsurf", + name: "Gemini process", processInfo: parentProcessInfo{ - Name: "windsurf", + Name: "gemini", }, - expectedAgent: AgentTypeWindsurf, + expectedAgent: AgentTypeGemini, detected: true, }, { - name: "Aider", + name: "OpenCode process", processInfo: parentProcessInfo{ - Name: "aider", + Name: "opencode", }, - expectedAgent: AgentTypeAider, + expectedAgent: AgentTypeOpenCode, detected: true, }, { @@ -319,7 +296,7 @@ func TestIsRunningInAgent(t *testing.T) { assert.False(t, IsRunningInAgent()) - t.Setenv("CURSOR_EDITOR", "1") + t.Setenv("GEMINI_CLI", "1") ResetDetection() assert.True(t, IsRunningInAgent()) @@ -333,28 +310,8 @@ func clearAgentEnvVars(t *testing.T) { "CLAUDE_CODE", "CLAUDE_CODE_ENTRYPOINT", // GitHub Copilot CLI "GITHUB_COPILOT_CLI", "GH_COPILOT", - // OpenAI Codex - "OPENAI_CODEX", "CODEX_CLI", - // Cursor - "CURSOR_EDITOR", "CURSOR_SESSION_ID", "CURSOR_TRACE_ID", - // Windsurf - "WINDSURF_EDITOR", "WINDSURF_SESSION", - // Zed - "ZED_TERM", - // Aider - "AIDER_MODEL", "AIDER_CHAT_LANGUAGE", - // Continue - "CONTINUE_GLOBAL_DIR", "CONTINUE_DEVELOPMENT", - // Amazon Q - "AMAZON_Q_DEVELOPER", "AWS_Q_DEVELOPER", "KIRO_CLI", - // Cline - "CLINE_MCP", - // Tabnine - "TABNINE_CONFIG", - // Cody - "CODY_CONFIG", // Gemini CLI - "GEMINI_CLI", "GEMINI_CLI_NO_RELAUNCH", "GEMINI_CODE_ASSIST", + "GEMINI_CLI", "GEMINI_CLI_NO_RELAUNCH", // OpenCode "OPENCODE", // User agent diff --git a/cli/azd/internal/runcontext/agentdetect/types.go b/cli/azd/internal/runcontext/agentdetect/types.go index 9b54266b228..cc972a1a80c 100644 --- a/cli/azd/internal/runcontext/agentdetect/types.go +++ b/cli/azd/internal/runcontext/agentdetect/types.go @@ -2,7 +2,7 @@ // Licensed under the MIT License. // Package agentdetect provides functionality to detect when azd is invoked -// by known AI coding agents (Claude Code, GitHub Copilot CLI, Cursor, etc.) +// by known AI coding agents (Claude Code, GitHub Copilot, Gemini, OpenCode) // and enables automatic adjustment of behavior (e.g., no-prompt mode). package agentdetect @@ -16,34 +16,12 @@ const ( AgentTypeClaudeCode AgentType = "claude-code" // AgentTypeGitHubCopilotCLI is GitHub's Copilot CLI agent. AgentTypeGitHubCopilotCLI AgentType = "github-copilot-cli" - // AgentTypeOpenAICodex is OpenAI's Codex CLI agent. - AgentTypeOpenAICodex AgentType = "openai-codex" - // AgentTypeCursor is the Cursor editor agent. - AgentTypeCursor AgentType = "cursor" - // AgentTypeWindsurf is the Windsurf editor agent (by Codeium). - AgentTypeWindsurf AgentType = "windsurf" - // AgentTypeAider is the Aider AI pair programming tool. - AgentTypeAider AgentType = "aider" - // AgentTypeContinue is the Continue coding assistant. - AgentTypeContinue AgentType = "continue" - // AgentTypeAmazonQ is Amazon Q Developer agent (formerly CodeWhisperer). - AgentTypeAmazonQ AgentType = "amazon-q" // AgentTypeVSCodeCopilot is VS Code GitHub Copilot extension. AgentTypeVSCodeCopilot AgentType = "vscode-copilot" - // AgentTypeCline is the Cline VS Code extension (formerly Claude Dev). - AgentTypeCline AgentType = "cline" - // AgentTypeZed is the Zed editor with AI features. - AgentTypeZed AgentType = "zed" - // AgentTypeTabnine is the Tabnine AI coding assistant. - AgentTypeTabnine AgentType = "tabnine" - // AgentTypeCody is Sourcegraph's Cody AI assistant. - AgentTypeCody AgentType = "cody" // AgentTypeGemini is Google's Gemini CLI. AgentTypeGemini AgentType = "gemini" // AgentTypeOpenCode is the OpenCode AI coding CLI. AgentTypeOpenCode AgentType = "opencode" - // AgentTypeGeneric indicates an agent was detected but not specifically identified. - AgentTypeGeneric AgentType = "generic" ) // String returns the string representation of the agent type. @@ -58,34 +36,12 @@ func (a AgentType) DisplayName() string { return "Claude Code" case AgentTypeGitHubCopilotCLI: return "GitHub Copilot CLI" - case AgentTypeOpenAICodex: - return "OpenAI Codex" - case AgentTypeCursor: - return "Cursor" - case AgentTypeWindsurf: - return "Windsurf" - case AgentTypeAider: - return "Aider" - case AgentTypeContinue: - return "Continue" - case AgentTypeAmazonQ: - return "Amazon Q Developer" case AgentTypeVSCodeCopilot: return "VS Code GitHub Copilot" - case AgentTypeCline: - return "Cline" - case AgentTypeZed: - return "Zed" - case AgentTypeTabnine: - return "Tabnine" - case AgentTypeCody: - return "Cody" case AgentTypeGemini: return "Gemini" case AgentTypeOpenCode: return "OpenCode" - case AgentTypeGeneric: - return "Generic Agent" default: return "Unknown" } diff --git a/cli/azd/internal/terminal/terminal.go b/cli/azd/internal/terminal/terminal.go index 55f86d12d46..1d828c1d231 100644 --- a/cli/azd/internal/terminal/terminal.go +++ b/cli/azd/internal/terminal/terminal.go @@ -7,6 +7,7 @@ import ( "os" "strconv" + "github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect" "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" "github.com/mattn/go-isatty" ) @@ -26,5 +27,10 @@ func IsTerminal(stdoutFd uintptr, stdinFd uintptr) bool { return false } + // If running under an AI coding agent, disable TTY mode to prevent interactive prompts. + if agentdetect.IsRunningInAgent() { + return false + } + return isatty.IsTerminal(stdoutFd) && isatty.IsTerminal(stdinFd) } diff --git a/cli/azd/internal/terminal/terminal_test.go b/cli/azd/internal/terminal/terminal_test.go new file mode 100644 index 00000000000..6dcb98603bd --- /dev/null +++ b/cli/azd/internal/terminal/terminal_test.go @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package terminal + +import ( + "os" + "testing" + + "github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect" + "github.com/stretchr/testify/assert" +) + +func TestIsTerminal_ForceTTY(t *testing.T) { + clearTestEnvVars(t) + agentdetect.ResetDetection() + + // Test AZD_FORCE_TTY=true forces TTY mode + t.Setenv("AZD_FORCE_TTY", "true") + assert.True(t, IsTerminal(0, 0), "AZD_FORCE_TTY=true should force TTY mode") + + // Test AZD_FORCE_TTY=false forces non-TTY mode + t.Setenv("AZD_FORCE_TTY", "false") + assert.False(t, IsTerminal(0, 0), "AZD_FORCE_TTY=false should disable TTY mode") +} + +func TestIsTerminal_AgentDetection(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + expected bool + }{ + { + name: "Claude Code agent disables TTY", + envVars: map[string]string{"CLAUDE_CODE": "1"}, + expected: false, + }, + { + name: "GitHub Copilot CLI disables TTY", + envVars: map[string]string{"GITHUB_COPILOT_CLI": "true"}, + expected: false, + }, + { + name: "Gemini CLI disables TTY", + envVars: map[string]string{"GEMINI_CLI": "1"}, + expected: false, + }, + { + name: "OpenCode disables TTY", + envVars: map[string]string{"OPENCODE": "1"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearTestEnvVars(t) + agentdetect.ResetDetection() + + for k, v := range tt.envVars { + t.Setenv(k, v) + } + + result := IsTerminal(0, 0) + assert.Equal(t, tt.expected, result, + "IsTerminal should return %v when agent is detected", tt.expected) + }) + } +} + +func TestIsTerminal_ForceTTYOverridesAgent(t *testing.T) { + clearTestEnvVars(t) + agentdetect.ResetDetection() + + // Set an agent env var that would normally disable TTY + t.Setenv("CLAUDE_CODE", "1") + + // But AZD_FORCE_TTY should take precedence + t.Setenv("AZD_FORCE_TTY", "true") + + assert.True(t, IsTerminal(0, 0), + "AZD_FORCE_TTY=true should override agent detection and enable TTY") +} + +// clearTestEnvVars clears environment variables that affect terminal detection. +func clearTestEnvVars(t *testing.T) { + envVarsToUnset := []string{ + "AZD_FORCE_TTY", + // Agent env vars + "CLAUDE_CODE", "CLAUDE_CODE_ENTRYPOINT", + "GITHUB_COPILOT_CLI", "GH_COPILOT", + "GEMINI_CLI", "GEMINI_CLI_NO_RELAUNCH", + "OPENCODE", + // CI env vars + "CI", "TF_BUILD", "GITHUB_ACTIONS", + } + + for _, envVar := range envVarsToUnset { + if _, exists := os.LookupEnv(envVar); exists { + t.Setenv(envVar, "") + os.Unsetenv(envVar) + } + } +} diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go index e7bfeb182af..32b6d039840 100644 --- a/cli/azd/internal/tracing/fields/fields.go +++ b/cli/azd/internal/tracing/fields/fields.go @@ -279,19 +279,8 @@ const ( // AI Coding Agent environments EnvClaudeCode = "Claude Code" EnvGitHubCopilotCLI = "GitHub Copilot CLI" - EnvOpenAICodex = "OpenAI Codex" - EnvCursor = "Cursor" - EnvWindsurf = "Windsurf" - EnvAider = "Aider" - EnvContinue = "Continue" - EnvAmazonQ = "Amazon Q Developer" - EnvCline = "Cline" - EnvZed = "Zed" - EnvTabnine = "Tabnine" - EnvCody = "Cody" - EnvGemini = "Gemini CLI" + EnvGemini = "Gemini" EnvOpenCode = "OpenCode" - EnvGenericAgent = "Generic Agent" // Continuous Integration environments diff --git a/cli/azd/internal/tracing/resource/exec_environment.go b/cli/azd/internal/tracing/resource/exec_environment.go index 1a4eb9846bb..d5b710194fb 100644 --- a/cli/azd/internal/tracing/resource/exec_environment.go +++ b/cli/azd/internal/tracing/resource/exec_environment.go @@ -75,36 +75,14 @@ func execEnvFromAgent() string { return fields.EnvClaudeCode case agentdetect.AgentTypeGitHubCopilotCLI: return fields.EnvGitHubCopilotCLI - case agentdetect.AgentTypeOpenAICodex: - return fields.EnvOpenAICodex - case agentdetect.AgentTypeCursor: - return fields.EnvCursor - case agentdetect.AgentTypeWindsurf: - return fields.EnvWindsurf - case agentdetect.AgentTypeAider: - return fields.EnvAider - case agentdetect.AgentTypeContinue: - return fields.EnvContinue - case agentdetect.AgentTypeAmazonQ: - return fields.EnvAmazonQ case agentdetect.AgentTypeVSCodeCopilot: return fields.EnvVSCodeAzureCopilot - case agentdetect.AgentTypeCline: - return fields.EnvCline - case agentdetect.AgentTypeZed: - return fields.EnvZed - case agentdetect.AgentTypeTabnine: - return fields.EnvTabnine - case agentdetect.AgentTypeCody: - return fields.EnvCody case agentdetect.AgentTypeGemini: return fields.EnvGemini case agentdetect.AgentTypeOpenCode: return fields.EnvOpenCode - case agentdetect.AgentTypeGeneric: - return fields.EnvGenericAgent default: - return fields.EnvGenericAgent + return "" } } From f3a09523942a143f4ea817309b996dd847140dce Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 29 Jan 2026 15:42:10 -0800 Subject: [PATCH 5/6] Updates spelling --- cli/azd/.vscode/cspell.yaml | 9 --------- 1 file changed, 9 deletions(-) diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 028bdf85101..49d6c804e00 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -10,8 +10,6 @@ words: - Chans - chinacloudapi - cmds - - Codeium - - CODEWHISPERER - Codespace - Codespaces - cooldown @@ -26,8 +24,6 @@ words: - OPENCODE - opencode - grpcbroker - - KIRO - - kiro - nosec - oneof - idxs @@ -44,15 +40,10 @@ words: - protojson - protoreflect - SNAPPROCESS - - Sourcegraph - - sourcegraph - structpb - Retryable - runcontext - surveyterm - - Tabnine - - tabnine - - TABNINE - Toolhelp - unmarshals - unmarshaling From beadd3afd85b6e963bb779c81c3e7956ee01630d Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 29 Jan 2026 15:49:26 -0800 Subject: [PATCH 6/6] Fixes linux lint issue --- cli/azd/internal/runcontext/agentdetect/detect_process.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cli/azd/internal/runcontext/agentdetect/detect_process.go b/cli/azd/internal/runcontext/agentdetect/detect_process.go index a5f8ba4e5b5..a81b7eba4a5 100644 --- a/cli/azd/internal/runcontext/agentdetect/detect_process.go +++ b/cli/azd/internal/runcontext/agentdetect/detect_process.go @@ -75,8 +75,9 @@ func detectFromParentProcess() AgentInfo { // parentProcessInfo contains information about a parent process. type parentProcessInfo struct { - Name string - Executable string + Name string + Executable string + CommandLine string // Full command line (Linux/macOS only) } // matchProcessToAgent checks if a process matches any known AI agent patterns.