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..33e9732f489 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,46 @@ 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 = "." + } + 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{ + 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.src == "" { + flags.src = checkDir + } + } + } + } + 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..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,11 +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 = "." - } // 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 d625152183d..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" @@ -16,6 +17,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 +262,44 @@ 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, +// 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) + _, 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 "", nil +} + +// 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..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 @@ -298,3 +298,141 @@ 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, err := detectLocalManifest(dir) + require.NoError(t, err) + 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, err := detectLocalManifest(dir) + require.NoError(t, err) + 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, err := detectLocalManifest(dir) + require.NoError(t, err) + 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, err := detectLocalManifest(dir) + require.NoError(t, err) + 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, err := detectLocalManifest(dir) + require.NoError(t, err) + require.Empty(t, result) + }) + + t.Run("empty directory", func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + + result, err := detectLocalManifest(dir) + require.NoError(t, err) + 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, err := detectLocalManifest(dir) + require.NoError(t, err) + 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, err := detectLocalManifest(dir) + require.NoError(t, err) + 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, 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) + }) +}