Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions cli/azd/.vscode/cspell.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import: ../../../.vscode/cspell.global.yaml
words:
- agentdetect
- azcloud
- azdext
- azurefd
Expand All @@ -20,23 +21,30 @@ words:
# CDN host name
- gfgac2cmf7b8cuay
- goversioninfo
- OPENCODE
- opencode
- grpcbroker
- 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
- structpb
- Retryable
- runcontext
- surveyterm
- Toolhelp
- unmarshals
- unmarshaling
- unsetting
Expand Down
12 changes: 12 additions & 0 deletions cli/azd/cmd/auto_install.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -509,6 +510,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()

Expand Down Expand Up @@ -542,5 +546,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
}
133 changes: 133 additions & 0 deletions cli/azd/cmd/auto_install_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -72,3 +75,133 @@ 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: "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: "Gemini agent enables no-prompt automatically",
args: []string{"init"},
envVars: map[string]string{"GEMINI_CLI": "1"},
expectedNoPrompt: true,
description: "When running under Gemini, --no-prompt should be auto-enabled",
},
{
name: "OpenCode agent enables no-prompt automatically",
args: []string{"provision"},
envVars: map[string]string{"OPENCODE": "1"},
expectedNoPrompt: true,
description: "When running under OpenCode, --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) {
// Clear any ambient agent env vars to ensure test isolation
clearAgentEnvVarsForTest(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
}

// 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",
// Gemini CLI
"GEMINI_CLI", "GEMINI_CLI_NO_RELAUNCH",
// OpenCode
"OPENCODE",
// User agent
internal.AzdUserAgentEnvVar,
}

for _, envVar := range envVarsToUnset {
if _, exists := os.LookupEnv(envVar); exists {
t.Setenv(envVar, "")
os.Unsetenv(envVar)
}
}
}
86 changes: 86 additions & 0 deletions cli/azd/cmd/auto_install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -327,3 +330,86 @@ 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{"GEMINI_CLI": "1"},
expectedNoPrompt: true,
},
{
name: "no agent, --no-prompt explicitly set",
args: []string{"--no-prompt", "deploy"},
envVars: map[string]string{},
expectedNoPrompt: true,
},
{
name: "Gemini agent detected",
args: []string{"init"},
envVars: map[string]string{"GEMINI_CLI": "1"},
expectedNoPrompt: true,
},
{
name: "GitHub Copilot CLI agent detected",
args: []string{"deploy"},
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 {
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()

// 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()
})
}
}
66 changes: 66 additions & 0 deletions cli/azd/internal/runcontext/agentdetect/detect.go
Original file line number Diff line number Diff line change
@@ -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{}
}
Loading
Loading