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 75b529e5c64..be17ac5b591 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "io" + "io/fs" "log" "maps" "net/http" @@ -507,9 +508,10 @@ func runInitFromManifest( flags *initFlags, azdClient *azdext.AzdClient, httpClient *http.Client, + targetDir string, ) error { // Ensure project and environment exist (no subscription/location prompting yet) - projectConfig, err := ensureProject(ctx, flags, azdClient) + projectConfig, err := ensureProject(ctx, flags, azdClient, targetDir) if err != nil { return err } @@ -642,6 +644,20 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, Timeout: 30 * time.Second, } + // Capture the original working directory so we can print an + // accurate cd hint after the process has chdir'd. + originalCwd, err := os.Getwd() + if err != nil { + return fmt.Errorf("getting current directory: %w", err) + } + + // Track the absolute path of a newly created project folder so we + // can print a follow-up cd hint at the end of the command. + createdFolder := "" + // Original template title, used to surface a notice when the + // sanitized folder name differs significantly from what the user selected. + createdFromTitle := "" + // Auto-detect an existing agent manifest in the target directory // when no --manifest flag was provided. if flags.manifestPointer == "" { @@ -689,7 +705,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, return err } - if err := runInitFromManifest(ctx, flags, azdClient, httpClient); err != nil { + if err := runInitFromManifest(ctx, flags, azdClient, httpClient, "."); err != nil { if exterrors.IsCancellation(err) { return exterrors.Cancelled("initialization was cancelled") } @@ -719,17 +735,24 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, switch selectedTemplate.EffectiveType() { case TemplateTypeAzd: // Full azd template - dispatch azd init -t - initArgs := []string{"init", "-t", selectedTemplate.Source, "."} + // Create project in a new subdirectory derived from the template title. + folderName := folderNameStrippingParenSuffix(selectedTemplate.Title) + // Check whether the target directory already exists so we + // only report "created" when a new directory was made. + _, statErr := os.Stat(folderName) + newlyCreated := errors.Is(statErr, fs.ErrNotExist) + initArgs := []string{"init", "-t", selectedTemplate.Source, folderName} if flags.env != "" { initArgs = append(initArgs, "--environment", flags.env) } else { - cwd, err := os.Getwd() - if err == nil { - sanitizedDirectoryName := sanitizeAgentName(filepath.Base(cwd)) - initArgs = append( - initArgs, "--environment", sanitizedDirectoryName+"-dev", - ) + base := sanitizeAgentName(folderName) + if len(base) > 59 { + base = strings.TrimRight(base[:59], "-") } + defaultEnvName := base + "-dev" + initArgs = append( + initArgs, "--environment", defaultEnvName, + ) } workflow := &azdext.Workflow{ @@ -760,6 +783,19 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, selectedTemplate.Title, ) + // Sync the extension process into the new project directory. + // The azd host already chdir'd when it processed the init command. + if err := os.Chdir(folderName); err != nil { + return fmt.Errorf( + "changing to project directory %q: %w", + folderName, err, + ) + } + if newlyCreated { + createdFolder = filepath.Join(originalCwd, folderName) + createdFromTitle = selectedTemplate.Title + } + // Search for an agent manifest in the scaffolded project cwd, err := os.Getwd() if err != nil { @@ -773,7 +809,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, if manifestPath != "" { flags.manifestPointer = manifestPath - if err := runInitFromManifest(ctx, flags, azdClient, httpClient); err != nil { + if err := runInitFromManifest(ctx, flags, azdClient, httpClient, "."); err != nil { if exterrors.IsCancellation(err) { return exterrors.Cancelled("initialization was cancelled") } @@ -784,14 +820,26 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } default: - // Agent manifest template - use existing -m flow + // Agent manifest template - use existing -m flow. + // Create project in a new subdirectory derived from the template title. + folderName := folderNameStrippingParenSuffix(selectedTemplate.Title) + // Check whether the target directory already exists so we + // only report "created" when a new directory was made. + _, statErr := os.Stat(folderName) + newlyCreated := errors.Is(statErr, fs.ErrNotExist) flags.manifestPointer = selectedTemplate.Source - if err := runInitFromManifest(ctx, flags, azdClient, httpClient); err != nil { + if err := runInitFromManifest( + ctx, flags, azdClient, httpClient, folderName, + ); err != nil { if exterrors.IsCancellation(err) { return exterrors.Cancelled("initialization was cancelled") } return err } + if newlyCreated { + createdFolder = filepath.Join(originalCwd, folderName) + createdFromTitle = selectedTemplate.Title + } } default: @@ -811,6 +859,10 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } } + if createdFolder != "" { + fmt.Print(formatCreatedFolderMessage(originalCwd, createdFolder, createdFromTitle)) + } + return nil }, } @@ -994,21 +1046,37 @@ func (a *InitAction) Run(ctx context.Context) error { return nil } -func ensureProject(ctx context.Context, flags *initFlags, azdClient *azdext.AzdClient) (*azdext.ProjectConfig, error) { +func ensureProject( + ctx context.Context, + flags *initFlags, + azdClient *azdext.AzdClient, + targetDir string, +) (*azdext.ProjectConfig, error) { projectResponse, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) if err != nil { fmt.Println("Let's get your project initialized.") // Environment creation is handled separately in ensureEnvironment - initArgs := []string{"init", "-t", "Azure-Samples/azd-ai-starter-basic", "."} + initArgs := []string{ + "init", "-t", "Azure-Samples/azd-ai-starter-basic", targetDir, + } if flags.env != "" { initArgs = append(initArgs, "--environment", flags.env) } else { - cwd, err := os.Getwd() - if err == nil { - sanitizedDirectoryName := sanitizeAgentName(filepath.Base(cwd)) - initArgs = append(initArgs, "--environment", sanitizedDirectoryName+"-dev") + // Derive environment name from target folder + envBase := targetDir + if targetDir == "." { + cwd, cwdErr := os.Getwd() + if cwdErr == nil { + envBase = filepath.Base(cwd) + } } + base := sanitizeAgentName(envBase) + if len(base) > 59 { + base = strings.TrimRight(base[:59], "-") + } + envName := base + "-dev" + initArgs = append(initArgs, "--environment", envName) } // We don't have a project yet @@ -1035,6 +1103,17 @@ func ensureProject(ctx context.Context, flags *initFlags, azdClient *azdext.AzdC ) } + // Sync the extension process into the new project directory so that + // subsequent local file operations see the scaffolded project. + if targetDir != "." { + if chdirErr := os.Chdir(targetDir); chdirErr != nil { + return nil, fmt.Errorf( + "changing to project directory %q: %w", + targetDir, chdirErr, + ) + } + } + projectResponse, err = azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) if err != nil { return nil, exterrors.Dependency( @@ -2908,3 +2987,21 @@ func extractConnectionConfigs( return connections, credentialEnvVars, nil } + +// formatCreatedFolderMessage builds the user-facing message shown after a new +// project folder is created. It computes a cross-platform relative display path +// and optionally notes the original template title when the folder name differs. +func formatCreatedFolderMessage(originalCwd, createdFolder, createdFromTitle string) string { + displayPath := createdFolder + if relPath, err := filepath.Rel(originalCwd, createdFolder); err == nil { + displayPath = filepath.ToSlash(relPath) + } + + msg := fmt.Sprintf("\nYour project has been created in %s", displayPath) + if createdFromTitle != "" && filepath.Base(createdFolder) != createdFromTitle { + msg += fmt.Sprintf(" (from template %q)", createdFromTitle) + } + msg += fmt.Sprintf("\n cd %s\n", displayPath) + + return msg +} 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 4c1e9736765..d03dbfcb2cd 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 @@ -812,6 +812,13 @@ func sanitizeAgentName(name string) string { return name } +func folderNameStrippingParenSuffix(title string) string { + if idx := strings.IndexByte(title, '('); idx >= 0 { + title = strings.TrimSpace(title[:idx]) + } + return sanitizeAgentName(title) +} + // normalizeForFuzzyMatch strips common separator characters (hyphens, dots, spaces, underscores) // and lowercases the string for fuzzy comparison. func normalizeForFuzzyMatch(s string) string { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go index b6bd1cc9aa6..2a30bf69c78 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code_test.go @@ -100,6 +100,16 @@ func TestSanitizeAgentName(t *testing.T) { input: "---", expected: "my-agent", }, + { + name: "non-ASCII characters stripped", + input: "Ünö Ägent", + expected: "n-gent", + }, + { + name: "all non-ASCII falls back to default", + input: "日本語エージェント", + expected: "my-agent", + }, } for _, tt := range tests { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go index abdb310ba5e..607208040d6 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go @@ -6,6 +6,7 @@ package cmd import ( "context" "errors" + "io/fs" "net/http" "os" "path/filepath" @@ -2194,3 +2195,196 @@ func TestParseAuthStatusJSON(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// createdFolder path computation after Chdir +// (covers PR review — directory creation tracking and message accuracy) +// --------------------------------------------------------------------------- + +// TestCreatedFolderPath_AfterChdir verifies that formatCreatedFolderMessage +// produces the correct relative display path even after the process has +// chdir'd into the new project directory. +func TestCreatedFolderPath_AfterChdir(t *testing.T) { + tests := []struct { + name string + folder string + wantPath string + }{ + { + name: "simple folder name", + folder: "my-agent", + wantPath: "my-agent", + }, + { + name: "sanitized folder name", + folder: folderNameStrippingParenSuffix("Hello World (Python)"), + wantPath: "hello-world", + }, + { + name: "folder with numbers", + folder: "agent-v2", + wantPath: "agent-v2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + originalCwd := t.TempDir() + + // Create the subdirectory (simulates azd init creating it) + folderPath := filepath.Join(originalCwd, tt.folder) + //nolint:gosec // test fixture directory permissions are intentional + if err := os.MkdirAll(folderPath, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + // Simulate the chdir that happens after azd init + t.Chdir(folderPath) + + msg := formatCreatedFolderMessage(originalCwd, folderPath, "") + wantSuffix := "cd " + tt.wantPath + "\n" + if !strings.Contains(msg, tt.wantPath) { + t.Errorf("message missing display path %q:\n%s", tt.wantPath, msg) + } + if !strings.HasSuffix(msg, wantSuffix) { + t.Errorf("message should end with %q, got:\n%s", wantSuffix, msg) + } + }) + } +} + +// TestCreatedFolderPath_NotSetWhenDirectoryExists verifies that the +// newlyCreated check correctly identifies an existing directory. +func TestCreatedFolderPath_NotSetWhenDirectoryExists(t *testing.T) { + originalCwd := t.TempDir() + t.Chdir(originalCwd) + + folderName := "existing-project" + + // Pre-create the directory (simulates an existing project) + existingDir := filepath.Join(originalCwd, folderName) + //nolint:gosec // test fixture directory permissions are intentional + if err := os.MkdirAll(existingDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + + // Mirror production logic: stat + errors.Is + _, statErr := os.Stat(folderName) + newlyCreated := errors.Is(statErr, fs.ErrNotExist) + + if newlyCreated { + t.Error("newlyCreated should be false when directory already exists") + } +} + +// TestCreatedFolderPath_SetWhenDirectoryDoesNotExist verifies that the +// newlyCreated check correctly identifies a missing directory. +func TestCreatedFolderPath_SetWhenDirectoryDoesNotExist(t *testing.T) { + originalCwd := t.TempDir() + t.Chdir(originalCwd) + + folderName := "new-agent-project" + + // Do NOT create the directory — simulates fresh init + _, statErr := os.Stat(folderName) + newlyCreated := errors.Is(statErr, fs.ErrNotExist) + + if !newlyCreated { + t.Error("newlyCreated should be true when directory does not exist") + } + + // Verify formatCreatedFolderMessage produces valid output + createdFolder := filepath.Join(originalCwd, folderName) + msg := formatCreatedFolderMessage(originalCwd, createdFolder, "") + if !strings.Contains(msg, folderName) { + t.Errorf("message should contain %q:\n%s", folderName, msg) + } +} + +// TestCreatedFolderPath_AzdTemplateCase verifies the full flow for the +// TemplateTypeAzd case: folderNameFromTitle derives the name, and the message +// includes a template-title notice when the name changed. +func TestCreatedFolderPath_AzdTemplateCase(t *testing.T) { + originalCwd := t.TempDir() + + templateTitle := "Basic Agent (Python)" + folderName := folderNameStrippingParenSuffix(templateTitle) + + // folderNameFromTitle should strip parenthetical suffix + if strings.Contains(folderName, "python") { + t.Errorf("folderName should not contain parenthetical suffix, got %q", folderName) + } + + createdFolder := filepath.Join(originalCwd, folderName) + msg := formatCreatedFolderMessage(originalCwd, createdFolder, templateTitle) + + // Should contain the template notice since name differs from title + if !strings.Contains(msg, templateTitle) { + t.Errorf("message should reference original title %q:\n%s", templateTitle, msg) + } + // Should contain the cd hint + if !strings.Contains(msg, "cd "+folderName) { + t.Errorf("message should contain cd hint:\n%s", msg) + } +} + +// TestCreatedFolderPath_ManifestTemplateExistingProject verifies that no +// "created" message is produced when an existing project is found for the +// agent manifest template flow. +func TestCreatedFolderPath_ManifestTemplateExistingProject(t *testing.T) { + originalCwd := t.TempDir() + t.Chdir(originalCwd) + + folderName := "my-agent" + + // Pre-create directory and azure.yaml to simulate existing project + projectDir := filepath.Join(originalCwd, folderName) + //nolint:gosec // test fixture directory permissions are intentional + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + //nolint:gosec // test fixture file permissions are intentional + if err := os.WriteFile( + filepath.Join(projectDir, "azure.yaml"), + []byte("name: my-agent\n"), + 0o644, + ); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + // Mirror production logic: directory exists, so newlyCreated is false + _, statErr := os.Stat(folderName) + newlyCreated := errors.Is(statErr, fs.ErrNotExist) + + if newlyCreated { + t.Error("newlyCreated should be false for existing project directory") + } +} + +func TestFolderNameFromTitle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + title string + want string + }{ + {name: "strips parenthetical suffix", title: "Basic Agent (Python)", want: "basic-agent"}, + {name: "no parenthetical", title: "My Cool Agent", want: "my-cool-agent"}, + {name: "parenthetical with spaces", title: "Agent ( Preview )", want: "agent"}, + {name: "non-ASCII title", title: "Ünö Agent (Test)", want: "n-agent"}, + {name: "all non-ASCII before paren", title: "日本語 (Python)", want: "my-agent"}, + {name: "empty title", title: "", want: "my-agent"}, + {name: "only parenthetical", title: "(Python)", want: "my-agent"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := folderNameStrippingParenSuffix(tt.title) + if got != tt.want { + t.Errorf("folderNameFromTitle(%q) = %q, want %q", tt.title, got, tt.want) + } + }) + } +}