From 6f00b9ddf5e55cdeb0905938faf28e76e8d425b2 Mon Sep 17 00:00:00 2001 From: trangevi Date: Thu, 2 Apr 2026 15:24:35 -0700 Subject: [PATCH 1/3] Initial pass Signed-off-by: trangevi --- .../azure.ai.agents/internal/cmd/init.go | 33 +++++++ .../internal/cmd/init_from_code.go | 22 +++++ .../cmd/init_from_templates_helpers.go | 30 ++++++ .../cmd/init_from_templates_helpers_test.go | 98 +++++++++++++++++++ 4 files changed, 183 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 2aec48d32ae..a4db2ca62ef 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -210,6 +210,39 @@ func newInitCommand(rootFlags *rootFlagsDefinition) *cobra.Command { Timeout: 30 * time.Second, } + // Auto-detect an existing agent manifest in the target directory + // when no --manifest flag was provided. + if flags.manifestPointer == "" { + checkDir := flags.src + if checkDir == "" { + checkDir = "." + } + if detected := detectLocalManifest(checkDir); detected != "" { + useExisting := flags.NoPrompt + if !flags.NoPrompt { + confirmResp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: fmt.Sprintf( + "An existing agent manifest was found at %q. Use it?", + detected, + ), + DefaultValue: new(true), + }, + }) + if promptErr != nil { + if exterrors.IsCancellation(promptErr) { + return exterrors.Cancelled("initialization was cancelled") + } + return fmt.Errorf("prompting for manifest detection: %w", promptErr) + } + useExisting = *confirmResp.Value + } + if useExisting { + flags.manifestPointer = detected + } + } + } + if flags.manifestPointer != "" { if err := runInitFromManifest(ctx, flags, azdClient, httpClient); err != nil { if exterrors.IsCancellation(err) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index 232f9af6842..d14517d9d07 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -85,6 +85,28 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error { srcDir = "." } + // Check if agent.yaml already exists and prompt before overwriting + agentYamlPath := filepath.Join(srcDir, "agent.yaml") + if _, statErr := os.Stat(agentYamlPath); statErr == nil { + if !a.flags.NoPrompt { + confirmResp, err := a.azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: fmt.Sprintf("An agent.yaml already exists in %q. Overwrite?", srcDir), + DefaultValue: new(false), + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("overwrite confirmation was cancelled") + } + return fmt.Errorf("prompting for overwrite confirmation: %w", err) + } + if !*confirmResp.Value { + return exterrors.Cancelled("agent.yaml already exists; overwrite declined") + } + } + } + // Write the definition to a file in the src directory _, err := a.writeDefinitionToSrcDir(localDefinition, srcDir) if err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go index d625152183d..c043fb3bed6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go @@ -16,6 +16,7 @@ import ( "strings" "azureaiagent/internal/exterrors" + "azureaiagent/internal/pkg/agents/agent_yaml" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/fatih/color" @@ -260,3 +261,32 @@ func findAgentManifest(dir string) (string, error) { return found, nil } + +// detectLocalManifest checks only the immediate directory for an agent manifest file. +// Returns the path to the found manifest, preferring agent.manifest.yaml over agent.yaml, +// or an empty string if neither exists or none contain valid manifest content. +func detectLocalManifest(dir string) string { + // Prefer agent.manifest.yaml (richer manifest format) over agent.yaml. + for _, name := range []string{"agent.manifest.yaml", "agent.yaml"} { + candidate := filepath.Join(dir, name) + if _, err := os.Stat(candidate); err == nil { + if isValidManifestFile(candidate) { + return candidate + } + } + } + return "" +} + +// isValidManifestFile reads the file and checks whether it can be loaded as +// a valid AgentManifest via LoadAndValidateAgentManifest. +func isValidManifestFile(path string) bool { + //nolint:gosec // path comes from a known filename in a user-controlled directory + content, err := os.ReadFile(path) + if err != nil { + return false + } + + _, err = agent_yaml.LoadAndValidateAgentManifest(content) + return err == nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go index 3fa7ecaca6a..1ca31915d84 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go @@ -298,3 +298,101 @@ func TestDirIsEmpty(t *testing.T) { require.False(t, empty) }) } + +func TestDetectLocalManifest(t *testing.T) { + t.Parallel() + + // Valid agent manifest content (has template with kind + name) + validManifest := `name: test-agent +template: + kind: hosted + name: test-agent + protocols: + - protocol: responses + version: v1 +` + + t.Run("no manifest files", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.py"), []byte("print()"), 0600)) + + result := detectLocalManifest(dir) + require.Empty(t, result) + }) + + t.Run("valid agent.yaml", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(validManifest), 0600)) + + result := detectLocalManifest(dir) + require.Equal(t, filepath.Join(dir, "agent.yaml"), result) + }) + + t.Run("valid agent.manifest.yaml", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.manifest.yaml"), []byte(validManifest), 0600)) + + result := detectLocalManifest(dir) + require.Equal(t, filepath.Join(dir, "agent.manifest.yaml"), result) + }) + + t.Run("both files prefers agent.manifest.yaml", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(validManifest), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.manifest.yaml"), []byte(validManifest), 0600)) + + result := detectLocalManifest(dir) + require.Equal(t, filepath.Join(dir, "agent.manifest.yaml"), result) + }) + + t.Run("does not search subdirectories", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + subDir := filepath.Join(dir, "src") + require.NoError(t, os.MkdirAll(subDir, 0700)) + require.NoError(t, os.WriteFile(filepath.Join(subDir, "agent.yaml"), []byte(validManifest), 0600)) + + result := detectLocalManifest(dir) + require.Empty(t, result) + }) + + t.Run("empty directory", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + result := detectLocalManifest(dir) + require.Empty(t, result) + }) + + t.Run("invalid YAML content is skipped", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte("not: valid: yaml: ["), 0600)) + + result := detectLocalManifest(dir) + require.Empty(t, result) + }) + + t.Run("YAML without template is skipped", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte("foo: bar\n"), 0600)) + + result := detectLocalManifest(dir) + require.Empty(t, result) + }) + + t.Run("falls back to agent.yaml when manifest.yaml is invalid", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.manifest.yaml"), []byte("foo: bar\n"), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(validManifest), 0600)) + + result := detectLocalManifest(dir) + require.Equal(t, filepath.Join(dir, "agent.yaml"), result) + }) +} From ecbc2686ac49dd69c1c56a40d4fc35879ead9ee0 Mon Sep 17 00:00:00 2001 From: trangevi Date: Thu, 2 Apr 2026 15:33:02 -0700 Subject: [PATCH 2/3] Handle "targetting same directory" issue Signed-off-by: trangevi --- cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index a4db2ca62ef..2df0f36eac5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -239,6 +239,9 @@ func newInitCommand(rootFlags *rootFlagsDefinition) *cobra.Command { } if useExisting { flags.manifestPointer = detected + if flags.src == "" { + flags.src = checkDir + } } } } From d0076a2e92dd2772753c8c966eeeb658302b1a1a Mon Sep 17 00:00:00 2001 From: trangevi Date: Fri, 3 Apr 2026 08:53:08 -0700 Subject: [PATCH 3/3] PR comments Signed-off-by: trangevi --- .../azure.ai.agents/internal/cmd/init.go | 6 +- .../internal/cmd/init_from_code.go | 58 ++++++++++--------- .../cmd/init_from_templates_helpers.go | 33 +++++++---- .../cmd/init_from_templates_helpers_test.go | 58 ++++++++++++++++--- 4 files changed, 108 insertions(+), 47 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 2df0f36eac5..33e9732f489 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -217,7 +217,11 @@ func newInitCommand(rootFlags *rootFlagsDefinition) *cobra.Command { if checkDir == "" { checkDir = "." } - if detected := detectLocalManifest(checkDir); detected != "" { + detected, detectErr := detectLocalManifest(checkDir) + if detectErr != nil { + return fmt.Errorf("checking for existing manifest: %w", detectErr) + } + if detected != "" { useExisting := flags.NoPrompt if !flags.NoPrompt { confirmResp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go index d14517d9d07..736ebd60676 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go @@ -71,6 +71,37 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error { a.flags.src = relPath } + // Default src to current directory when not specified + srcDir := a.flags.src + if srcDir == "" { + srcDir = "." + } + + // Check if agent.yaml already exists before the interactive setup so the user + // doesn't complete the full agent configuration only to have it discarded. + agentYamlPath := filepath.Join(srcDir, "agent.yaml") + if _, statErr := os.Stat(agentYamlPath); statErr == nil { + if a.flags.NoPrompt { + return exterrors.Cancelled("agent.yaml already exists; overwrite declined in no-prompt mode") + } + + confirmResp, err := a.azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: fmt.Sprintf("An agent.yaml already exists in %q. Overwrite?", srcDir), + DefaultValue: new(false), + }, + }) + if err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("overwrite confirmation was cancelled") + } + return fmt.Errorf("prompting for overwrite confirmation: %w", err) + } + if !*confirmResp.Value { + return exterrors.Cancelled("agent.yaml already exists; overwrite declined") + } + } + // No manifest pointer provided - process local agent code // Create a definition based on user prompts localDefinition, err := a.createDefinitionFromLocalAgent(ctx) @@ -79,33 +110,6 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error { } if localDefinition != nil { - // Default src to current directory when not specified - srcDir := a.flags.src - if srcDir == "" { - srcDir = "." - } - - // Check if agent.yaml already exists and prompt before overwriting - agentYamlPath := filepath.Join(srcDir, "agent.yaml") - if _, statErr := os.Stat(agentYamlPath); statErr == nil { - if !a.flags.NoPrompt { - confirmResp, err := a.azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ - Options: &azdext.ConfirmOptions{ - Message: fmt.Sprintf("An agent.yaml already exists in %q. Overwrite?", srcDir), - DefaultValue: new(false), - }, - }) - if err != nil { - if exterrors.IsCancellation(err) { - return exterrors.Cancelled("overwrite confirmation was cancelled") - } - return fmt.Errorf("prompting for overwrite confirmation: %w", err) - } - if !*confirmResp.Value { - return exterrors.Cancelled("agent.yaml already exists; overwrite declined") - } - } - } // Write the definition to a file in the src directory _, err := a.writeDefinitionToSrcDir(localDefinition, srcDir) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go index c043fb3bed6..cd16a9d7ec7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers.go @@ -6,6 +6,7 @@ package cmd import ( "context" "encoding/json" + "errors" "fmt" "io" "io/fs" @@ -263,19 +264,31 @@ func findAgentManifest(dir string) (string, error) { } // detectLocalManifest checks only the immediate directory for an agent manifest file. -// Returns the path to the found manifest, preferring agent.manifest.yaml over agent.yaml, -// or an empty string if neither exists or none contain valid manifest content. -func detectLocalManifest(dir string) string { - // Prefer agent.manifest.yaml (richer manifest format) over agent.yaml. - for _, name := range []string{"agent.manifest.yaml", "agent.yaml"} { +// Returns the path to the found manifest (preferring agent.manifest.yaml over agent.yaml, +// then .yml variants), or an empty string if none contain valid manifest content. +// Returns a non-nil error for unexpected I/O failures (e.g. permission errors). +func detectLocalManifest(dir string) (string, error) { + candidates := []string{ + "agent.manifest.yaml", + "agent.yaml", + "agent.manifest.yml", + "agent.yml", + } + + for _, name := range candidates { candidate := filepath.Join(dir, name) - if _, err := os.Stat(candidate); err == nil { - if isValidManifestFile(candidate) { - return candidate - } + _, err := os.Stat(candidate) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return "", fmt.Errorf("checking for manifest %s: %w", candidate, err) + } + if isValidManifestFile(candidate) { + return candidate, nil } } - return "" + return "", nil } // isValidManifestFile reads the file and checks whether it can be loaded as diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go index 1ca31915d84..b5b34a853aa 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_templates_helpers_test.go @@ -317,7 +317,8 @@ template: dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "main.py"), []byte("print()"), 0600)) - result := detectLocalManifest(dir) + result, err := detectLocalManifest(dir) + require.NoError(t, err) require.Empty(t, result) }) @@ -326,7 +327,8 @@ template: dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(validManifest), 0600)) - result := detectLocalManifest(dir) + result, err := detectLocalManifest(dir) + require.NoError(t, err) require.Equal(t, filepath.Join(dir, "agent.yaml"), result) }) @@ -335,7 +337,8 @@ template: dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.manifest.yaml"), []byte(validManifest), 0600)) - result := detectLocalManifest(dir) + result, err := detectLocalManifest(dir) + require.NoError(t, err) require.Equal(t, filepath.Join(dir, "agent.manifest.yaml"), result) }) @@ -345,7 +348,8 @@ template: require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(validManifest), 0600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.manifest.yaml"), []byte(validManifest), 0600)) - result := detectLocalManifest(dir) + result, err := detectLocalManifest(dir) + require.NoError(t, err) require.Equal(t, filepath.Join(dir, "agent.manifest.yaml"), result) }) @@ -356,7 +360,8 @@ template: require.NoError(t, os.MkdirAll(subDir, 0700)) require.NoError(t, os.WriteFile(filepath.Join(subDir, "agent.yaml"), []byte(validManifest), 0600)) - result := detectLocalManifest(dir) + result, err := detectLocalManifest(dir) + require.NoError(t, err) require.Empty(t, result) }) @@ -364,7 +369,8 @@ template: t.Parallel() dir := t.TempDir() - result := detectLocalManifest(dir) + result, err := detectLocalManifest(dir) + require.NoError(t, err) require.Empty(t, result) }) @@ -373,7 +379,8 @@ template: dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte("not: valid: yaml: ["), 0600)) - result := detectLocalManifest(dir) + result, err := detectLocalManifest(dir) + require.NoError(t, err) require.Empty(t, result) }) @@ -382,7 +389,8 @@ template: dir := t.TempDir() require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte("foo: bar\n"), 0600)) - result := detectLocalManifest(dir) + result, err := detectLocalManifest(dir) + require.NoError(t, err) require.Empty(t, result) }) @@ -392,7 +400,39 @@ template: require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.manifest.yaml"), []byte("foo: bar\n"), 0600)) require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(validManifest), 0600)) - result := detectLocalManifest(dir) + result, err := detectLocalManifest(dir) + require.NoError(t, err) + require.Equal(t, filepath.Join(dir, "agent.yaml"), result) + }) + + t.Run("detects agent.yml variant", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yml"), []byte(validManifest), 0600)) + + result, err := detectLocalManifest(dir) + require.NoError(t, err) + require.Equal(t, filepath.Join(dir, "agent.yml"), result) + }) + + t.Run("detects agent.manifest.yml variant", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.manifest.yml"), []byte(validManifest), 0600)) + + result, err := detectLocalManifest(dir) + require.NoError(t, err) + require.Equal(t, filepath.Join(dir, "agent.manifest.yml"), result) + }) + + t.Run("prefers yaml over yml", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yaml"), []byte(validManifest), 0600)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "agent.yml"), []byte(validManifest), 0600)) + + result, err := detectLocalManifest(dir) + require.NoError(t, err) require.Equal(t, filepath.Join(dir, "agent.yaml"), result) }) }