Skip to content

Commit 552a595

Browse files
authored
Detects popular AI agents and defaults to no-prompt mode (#6633)
* Detects popular AI agents and defaults to no-prompt mode * Addresses PR feedback * Fixes unit test issue and adds support for opencode * Updates supported agents and updates TTY usage. * Updates spelling * Fixes linux lint issue
1 parent 3e45e71 commit 552a595

File tree

16 files changed

+1356
-0
lines changed

16 files changed

+1356
-0
lines changed

cli/azd/.vscode/cspell.yaml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import: ../../../.vscode/cspell.global.yaml
22
words:
3+
- agentdetect
34
- azcloud
45
- azdext
56
- azurefd
@@ -20,23 +21,30 @@ words:
2021
# CDN host name
2122
- gfgac2cmf7b8cuay
2223
- goversioninfo
24+
- OPENCODE
25+
- opencode
2326
- grpcbroker
2427
- nosec
2528
- oneof
2629
- idxs
2730
# Looks like the protogen has a spelling error for panics
2831
- pancis
32+
- Paren
2933
- pkgux
34+
- ppid
35+
- PPID
3036
- proto
3137
- protobuf
3238
- protoc
3339
- protoimpl
3440
- protojson
3541
- protoreflect
42+
- SNAPPROCESS
3643
- structpb
3744
- Retryable
3845
- runcontext
3946
- surveyterm
47+
- Toolhelp
4048
- unmarshals
4149
- unmarshaling
4250
- unsetting

cli/azd/cmd/auto_install.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"strings"
1414

1515
"github.com/azure/azure-dev/cli/azd/internal"
16+
"github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect"
1617
"github.com/azure/azure-dev/cli/azd/internal/tracing/resource"
1718
"github.com/azure/azure-dev/cli/azd/pkg/extensions"
1819
"github.com/azure/azure-dev/cli/azd/pkg/input"
@@ -509,6 +510,9 @@ func CreateGlobalFlagSet() *pflag.FlagSet {
509510
// Uses ParseErrorsAllowlist to gracefully ignore unknown flags (like extension-specific flags).
510511
// This function is designed to be called BEFORE Cobra command tree construction to enable
511512
// early access to global flag values for auto-install and other pre-execution logic.
513+
//
514+
// Agent Detection: If --no-prompt is not explicitly set and an AI coding agent (like Claude Code,
515+
// GitHub Copilot CLI, Cursor, etc.) is detected as the caller, NoPrompt is automatically enabled.
512516
func ParseGlobalFlags(args []string, opts *internal.GlobalCommandOptions) error {
513517
globalFlagSet := CreateGlobalFlagSet()
514518

@@ -542,5 +546,13 @@ func ParseGlobalFlags(args []string, opts *internal.GlobalCommandOptions) error
542546
opts.NoPrompt = boolVal
543547
}
544548

549+
// Agent Detection: If --no-prompt was not explicitly set and we detect an AI coding agent
550+
// as the caller, automatically enable no-prompt mode for non-interactive execution.
551+
noPromptFlag := globalFlagSet.Lookup("no-prompt")
552+
noPromptExplicitlySet := noPromptFlag != nil && noPromptFlag.Changed
553+
if !noPromptExplicitlySet && agentdetect.IsRunningInAgent() {
554+
opts.NoPrompt = true
555+
}
556+
545557
return nil
546558
}

cli/azd/cmd/auto_install_integration_test.go

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ import (
77
"os"
88
"testing"
99

10+
"github.com/azure/azure-dev/cli/azd/internal"
11+
"github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect"
1012
"github.com/spf13/cobra"
1113
"github.com/stretchr/testify/assert"
14+
"github.com/stretchr/testify/require"
1215
)
1316

1417
// TestExecuteWithAutoInstallIntegration tests the integration between
@@ -72,3 +75,133 @@ func TestExecuteWithAutoInstallIntegration(t *testing.T) {
7275
// Restore original args
7376
os.Args = originalArgs
7477
}
78+
79+
// TestAgentDetectionIntegration tests the full agent detection integration flow.
80+
func TestAgentDetectionIntegration(t *testing.T) {
81+
tests := []struct {
82+
name string
83+
args []string
84+
envVars map[string]string
85+
expectedNoPrompt bool
86+
description string
87+
}{
88+
{
89+
name: "Claude Code agent enables no-prompt automatically",
90+
args: []string{"version"},
91+
envVars: map[string]string{"CLAUDE_CODE": "1"},
92+
expectedNoPrompt: true,
93+
description: "When running under Claude Code, --no-prompt should be auto-enabled",
94+
},
95+
{
96+
name: "GitHub Copilot CLI enables no-prompt automatically",
97+
args: []string{"deploy"},
98+
envVars: map[string]string{"GITHUB_COPILOT_CLI": "true"},
99+
expectedNoPrompt: true,
100+
description: "When running under GitHub Copilot CLI, --no-prompt should be auto-enabled",
101+
},
102+
{
103+
name: "Gemini agent enables no-prompt automatically",
104+
args: []string{"init"},
105+
envVars: map[string]string{"GEMINI_CLI": "1"},
106+
expectedNoPrompt: true,
107+
description: "When running under Gemini, --no-prompt should be auto-enabled",
108+
},
109+
{
110+
name: "OpenCode agent enables no-prompt automatically",
111+
args: []string{"provision"},
112+
envVars: map[string]string{"OPENCODE": "1"},
113+
expectedNoPrompt: true,
114+
description: "When running under OpenCode, --no-prompt should be auto-enabled",
115+
},
116+
{
117+
name: "User can override agent detection with --no-prompt=false",
118+
args: []string{"--no-prompt=false", "up"},
119+
envVars: map[string]string{"CLAUDE_CODE": "1"},
120+
expectedNoPrompt: false,
121+
description: "Explicit --no-prompt=false should override agent detection",
122+
},
123+
{
124+
name: "Normal execution without agent detection",
125+
args: []string{"version"},
126+
envVars: map[string]string{},
127+
expectedNoPrompt: false,
128+
description: "Without agent detection, prompting should remain enabled by default",
129+
},
130+
{
131+
name: "User agent string triggers detection",
132+
args: []string{"up"},
133+
envVars: map[string]string{
134+
internal.AzdUserAgentEnvVar: "claude-code/1.0.0",
135+
},
136+
expectedNoPrompt: true,
137+
description: "AZURE_DEV_USER_AGENT containing agent identifier should trigger detection",
138+
},
139+
}
140+
141+
for _, tt := range tests {
142+
t.Run(tt.name, func(t *testing.T) {
143+
// Clear any ambient agent env vars to ensure test isolation
144+
clearAgentEnvVarsForTest(t)
145+
146+
// Reset agent detection cache for each test
147+
agentdetect.ResetDetection()
148+
149+
// Set environment variables
150+
for k, v := range tt.envVars {
151+
t.Setenv(k, v)
152+
}
153+
154+
// Parse global flags as would happen in real execution
155+
opts := &internal.GlobalCommandOptions{}
156+
err := ParseGlobalFlags(tt.args, opts)
157+
require.NoError(t, err, "ParseGlobalFlags should not error: %s", tt.description)
158+
159+
assert.Equal(t, tt.expectedNoPrompt, opts.NoPrompt,
160+
"NoPrompt mismatch: %s", tt.description)
161+
162+
// Verify agent detection status matches expectation
163+
agent := agentdetect.GetCallingAgent()
164+
if tt.expectedNoPrompt && len(tt.envVars) > 0 && !containsNoPromptFalse(tt.args) {
165+
assert.True(t, agent.Detected,
166+
"Agent should be detected when agent env vars are set: %s", tt.description)
167+
}
168+
169+
// Clean up
170+
agentdetect.ResetDetection()
171+
})
172+
}
173+
}
174+
175+
// containsNoPromptFalse checks if args contain --no-prompt=false
176+
func containsNoPromptFalse(args []string) bool {
177+
for _, arg := range args {
178+
if arg == "--no-prompt=false" {
179+
return true
180+
}
181+
}
182+
return false
183+
}
184+
185+
// clearAgentEnvVarsForTest clears all environment variables that could trigger agent detection.
186+
// This ensures tests are isolated from the ambient environment.
187+
func clearAgentEnvVarsForTest(t *testing.T) {
188+
envVarsToUnset := []string{
189+
// Claude Code
190+
"CLAUDE_CODE", "CLAUDE_CODE_ENTRYPOINT",
191+
// GitHub Copilot CLI
192+
"GITHUB_COPILOT_CLI", "GH_COPILOT",
193+
// Gemini CLI
194+
"GEMINI_CLI", "GEMINI_CLI_NO_RELAUNCH",
195+
// OpenCode
196+
"OPENCODE",
197+
// User agent
198+
internal.AzdUserAgentEnvVar,
199+
}
200+
201+
for _, envVar := range envVarsToUnset {
202+
if _, exists := os.LookupEnv(envVar); exists {
203+
t.Setenv(envVar, "")
204+
os.Unsetenv(envVar)
205+
}
206+
}
207+
}

cli/azd/cmd/auto_install_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ import (
77
"strings"
88
"testing"
99

10+
"github.com/azure/azure-dev/cli/azd/internal"
11+
"github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect"
1012
"github.com/azure/azure-dev/cli/azd/pkg/extensions"
1113
"github.com/spf13/cobra"
1214
"github.com/stretchr/testify/assert"
15+
"github.com/stretchr/testify/require"
1316
)
1417

1518
func TestFindFirstNonFlagArg(t *testing.T) {
@@ -327,3 +330,86 @@ func TestCheckForMatchingExtension_Unit(t *testing.T) {
327330
})
328331
}
329332
}
333+
334+
func TestParseGlobalFlags_AgentDetection(t *testing.T) {
335+
tests := []struct {
336+
name string
337+
args []string
338+
envVars map[string]string
339+
expectedNoPrompt bool
340+
}{
341+
{
342+
name: "no agent detected, no flag",
343+
args: []string{"up"},
344+
envVars: map[string]string{},
345+
expectedNoPrompt: false,
346+
},
347+
{
348+
name: "agent detected via env var, no flag",
349+
args: []string{"up"},
350+
envVars: map[string]string{"CLAUDE_CODE": "1"},
351+
expectedNoPrompt: true,
352+
},
353+
{
354+
name: "agent detected but --no-prompt=false explicitly set",
355+
args: []string{"--no-prompt=false", "up"},
356+
envVars: map[string]string{"CLAUDE_CODE": "1"},
357+
expectedNoPrompt: false,
358+
},
359+
{
360+
name: "agent detected but --no-prompt explicitly set true",
361+
args: []string{"--no-prompt", "up"},
362+
envVars: map[string]string{"GEMINI_CLI": "1"},
363+
expectedNoPrompt: true,
364+
},
365+
{
366+
name: "no agent, --no-prompt explicitly set",
367+
args: []string{"--no-prompt", "deploy"},
368+
envVars: map[string]string{},
369+
expectedNoPrompt: true,
370+
},
371+
{
372+
name: "Gemini agent detected",
373+
args: []string{"init"},
374+
envVars: map[string]string{"GEMINI_CLI": "1"},
375+
expectedNoPrompt: true,
376+
},
377+
{
378+
name: "GitHub Copilot CLI agent detected",
379+
args: []string{"deploy"},
380+
envVars: map[string]string{"GITHUB_COPILOT_CLI": "true"},
381+
expectedNoPrompt: true,
382+
},
383+
{
384+
name: "OpenCode agent detected",
385+
args: []string{"provision"},
386+
envVars: map[string]string{"OPENCODE": "1"},
387+
expectedNoPrompt: true,
388+
},
389+
}
390+
391+
for _, tt := range tests {
392+
t.Run(tt.name, func(t *testing.T) {
393+
// Clear any ambient agent env vars to ensure test isolation
394+
clearAgentEnvVarsForTest(t)
395+
396+
// Reset agent detection cache
397+
agentdetect.ResetDetection()
398+
399+
// Set up env vars for this test
400+
for k, v := range tt.envVars {
401+
t.Setenv(k, v)
402+
}
403+
404+
opts := &internal.GlobalCommandOptions{}
405+
err := ParseGlobalFlags(tt.args, opts)
406+
require.NoError(t, err)
407+
408+
assert.Equal(t, tt.expectedNoPrompt, opts.NoPrompt,
409+
"NoPrompt should be %v for test case: %s", tt.expectedNoPrompt, tt.name)
410+
411+
// Clean up for next test
412+
agentdetect.ResetDetection()
413+
})
414+
}
415+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT License.
3+
4+
package agentdetect
5+
6+
import (
7+
"log"
8+
"sync"
9+
)
10+
11+
var (
12+
cachedAgent AgentInfo
13+
detectOnce sync.Once
14+
)
15+
16+
// GetCallingAgent detects if azd was invoked by a known AI coding agent.
17+
// The result is cached after the first call.
18+
func GetCallingAgent() AgentInfo {
19+
detectOnce.Do(func() {
20+
cachedAgent = detectAgent()
21+
if cachedAgent.Detected {
22+
log.Printf("Agent detection result: detected=%t, agent=%s, source=%s, details=%s",
23+
cachedAgent.Detected, cachedAgent.Name, cachedAgent.Source, cachedAgent.Details)
24+
} else {
25+
log.Printf("Agent detection result: detected=%t, no AI coding agent detected",
26+
cachedAgent.Detected)
27+
}
28+
})
29+
return cachedAgent
30+
}
31+
32+
// IsRunningInAgent returns true if azd was invoked by a known AI coding agent.
33+
func IsRunningInAgent() bool {
34+
return GetCallingAgent().Detected
35+
}
36+
37+
// detectAgent performs the actual agent detection.
38+
// Detection is performed in priority order:
39+
// 1. Environment variables (most reliable)
40+
// 2. User agent string (AZURE_DEV_USER_AGENT)
41+
// 3. Parent process inspection (fallback)
42+
func detectAgent() AgentInfo {
43+
// Try environment variable detection first (most reliable)
44+
if agent := detectFromEnvVars(); agent.Detected {
45+
return agent
46+
}
47+
48+
// Try user agent string detection
49+
if agent := detectFromUserAgent(); agent.Detected {
50+
return agent
51+
}
52+
53+
// Try parent process detection as fallback
54+
if agent := detectFromParentProcess(); agent.Detected {
55+
return agent
56+
}
57+
58+
return NoAgent()
59+
}
60+
61+
// ResetDetection clears the cached detection result.
62+
// This is primarily useful for testing.
63+
func ResetDetection() {
64+
detectOnce = sync.Once{}
65+
cachedAgent = AgentInfo{}
66+
}

0 commit comments

Comments
 (0)