Skip to content
Open
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
6758dbb
feat(init): create project folder during initialization
banrahan May 15, 2026
f9b8e7f
Merge branch 'Azure:main' into create-folder-during-init
banrahan May 15, 2026
a5b2c81
Merge branch 'Azure:main' into create-folder-during-init
banrahan May 18, 2026
fa96898
The environment name needed to be sanitized to avoid running over 63 …
banrahan May 19, 2026
a0f5b26
Capturing original working directory during project initialization an…
banrahan May 19, 2026
8104411
Refactor environment name sanitization and add tests for created fold…
banrahan May 19, 2026
25b5247
Refactor folder name handling during project initialization to improv…
banrahan May 19, 2026
3d078e0
Potential fix for pull request finding
banrahan May 20, 2026
7f9c01e
Improve project creation message to display correct folder path
banrahan May 20, 2026
2dafeba
Check if target directory exists before reporting creation during pro…
banrahan May 20, 2026
6622deb
address wbreza #1 issue: 1. folderNameFromTitle is only used in one o…
banrahan May 20, 2026
4cf0168
address wbreza comment 2. Env-name derivation is inconsistent between…
banrahan May 20, 2026
fc194b6
Address wbreza issue 3: Add notice for folder name discrepancies duri…
banrahan May 20, 2026
603cb07
wbreza issue 4 Fix path display for created folder to ensure consiste…
banrahan May 20, 2026
ebba15b
wbreza 4/5 Improve project creation message format and include folder…
banrahan May 20, 2026
308bf6f
6. Long titles can truncate -dev off the env name
banrahan May 20, 2026
d8c7d26
Refactor folder creation logic and improve user-facing messages for p…
banrahan May 20, 2026
d7b0c4d
Add tests for folder name generation and non-ASCII character handling
banrahan May 20, 2026
6fa030f
Rename folderNameFromTitle to folderNameStrippingParenSuffix for clar…
banrahan May 20, 2026
0fe5240
Merge branch 'main' into create-folder-during-init
banrahan May 21, 2026
e61333b
Merge branch 'Azure:main' into create-folder-during-init
banrahan May 21, 2026
3b76a40
Merge branch 'Azure:main' into create-folder-during-init
banrahan May 21, 2026
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
133 changes: 115 additions & 18 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"log"
"maps"
"net/http"
Expand Down Expand Up @@ -214,9 +215,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
}
Expand Down Expand Up @@ -330,6 +332,20 @@ func newInitCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
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 == "" {
Expand Down Expand Up @@ -377,7 +393,7 @@ func newInitCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
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")
}
Expand Down Expand Up @@ -407,17 +423,24 @@ func newInitCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
switch selectedTemplate.EffectiveType() {
case TemplateTypeAzd:
// Full azd template - dispatch azd init -t <repo>
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}
Comment thread
banrahan marked this conversation as resolved.
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,
)
Comment thread
banrahan marked this conversation as resolved.
}

workflow := &azdext.Workflow{
Expand Down Expand Up @@ -448,6 +471,19 @@ func newInitCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
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",
Comment thread
banrahan marked this conversation as resolved.
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 {
Expand All @@ -461,7 +497,7 @@ func newInitCommand(extCtx *azdext.ExtensionContext) *cobra.Command {

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")
}
Expand All @@ -472,14 +508,26 @@ func newInitCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
}

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:
Expand All @@ -499,6 +547,10 @@ func newInitCommand(extCtx *azdext.ExtensionContext) *cobra.Command {
}
}

if createdFolder != "" {
fmt.Print(formatCreatedFolderMessage(originalCwd, createdFolder, createdFromTitle))
}
Comment thread
banrahan marked this conversation as resolved.

return nil
},
}
Expand Down Expand Up @@ -655,21 +707,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,
}
Comment thread
banrahan marked this conversation as resolved.
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)
Comment thread
banrahan marked this conversation as resolved.
}

// We don't have a project yet
Expand All @@ -696,6 +764,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{})
Comment thread
banrahan marked this conversation as resolved.
if err != nil {
return nil, exterrors.Dependency(
Expand Down Expand Up @@ -2515,3 +2594,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
}
Comment thread
banrahan marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -731,6 +731,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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading