Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
841fe03
feat(init): create project folder during initialization
banrahan May 15, 2026
377e3ea
The environment name needed to be sanitized to avoid running over 63 …
banrahan May 19, 2026
5c52867
Capturing original working directory during project initialization an…
banrahan May 19, 2026
87bf848
Refactor environment name sanitization and add tests for created fold…
banrahan May 19, 2026
a4525d8
Refactor folder name handling during project initialization to improv…
banrahan May 19, 2026
4637d82
Potential fix for pull request finding
banrahan May 20, 2026
2ca5f2a
Improve project creation message to display correct folder path
banrahan May 20, 2026
bf13109
Check if target directory exists before reporting creation during pro…
banrahan May 20, 2026
bd739c1
address wbreza #1 issue: 1. folderNameFromTitle is only used in one o…
banrahan May 20, 2026
ac856e9
address wbreza comment 2. Env-name derivation is inconsistent between…
banrahan May 20, 2026
4e9ca91
Address wbreza issue 3: Add notice for folder name discrepancies duri…
banrahan May 20, 2026
4ab73ad
wbreza issue 4 Fix path display for created folder to ensure consiste…
banrahan May 20, 2026
48518be
wbreza 4/5 Improve project creation message format and include folder…
banrahan May 20, 2026
f0f78c8
6. Long titles can truncate -dev off the env name
banrahan May 20, 2026
c510ae3
Refactor folder creation logic and improve user-facing messages for p…
banrahan May 20, 2026
9fa8c62
Add tests for folder name generation and non-ASCII character handling
banrahan May 20, 2026
74d3d3c
Rename folderNameFromTitle to folderNameStrippingParenSuffix for clar…
banrahan May 20, 2026
ae97712
Changing to use the nextsteps code for suggesting the cd command
banrahan May 26, 2026
dcf8ac2
address comments from @trangevi
banrahan May 26, 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
167 changes: 135 additions & 32 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 @@ -87,11 +88,12 @@ type InitAction struct {
flags *initFlags
models *modelSelector

deploymentDetails []project.Deployment
containerSettings *project.ContainerSettings
isCodeDeploy bool // true when user selects code deploy mode; skips ACR config
httpClient *http.Client
serviceNameOverride string // when set, addToProject uses this instead of the manifest name
deploymentDetails []project.Deployment
containerSettings *project.ContainerSettings
isCodeDeploy bool // true when user selects code deploy mode; skips ACR config
httpClient *http.Client
serviceNameOverride string // when set, addToProject uses this instead of the manifest name
createdFolderDisplay string // pre-computed relative display path for the created folder
}

// modelSelector encapsulates the dependencies needed for model selection and
Expand Down Expand Up @@ -304,6 +306,13 @@ func setAgentNameOnTemplate(agentManifest *agent_yaml.AgentManifest, agentName s
return nil
}

func folderNameStrippingParenSuffix(title string) string {
if idx := strings.IndexByte(title, '('); idx >= 0 {
title = strings.TrimSpace(title[:idx])
}
return sanitizeAgentName(title)
}

func updateAgentDefinition(
template any,
update func(*agent_yaml.AgentDefinition),
Expand Down Expand Up @@ -530,9 +539,11 @@ func runInitFromManifest(
flags *initFlags,
azdClient *azdext.AzdClient,
httpClient *http.Client,
targetDir string,
createdFolderDisplay 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 @@ -582,14 +593,15 @@ func runInitFromManifest(
)

action := &InitAction{
azdClient: azdClient,
azureContext: azureContext,
console: console,
credential: credential,
projectConfig: projectConfig,
environment: env,
flags: flags,
httpClient: httpClient,
azdClient: azdClient,
azureContext: azureContext,
console: console,
credential: credential,
projectConfig: projectConfig,
environment: env,
flags: flags,
httpClient: httpClient,
createdFolderDisplay: createdFolderDisplay,
}

return action.Run(ctx)
Expand Down Expand Up @@ -669,6 +681,11 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
Timeout: 30 * time.Second,
}

// Track whether a project already exists so the cd hint is
// only shown for brand-new top-level project folders, not
// when a template adds a subfolder to an existing project.
existingProject := fileExists("azure.yaml")

// Auto-detect an existing agent manifest in the target directory
// when no --manifest flag was provided.
//
Expand Down Expand Up @@ -763,7 +780,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")
}
Expand Down Expand Up @@ -793,17 +810,24 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`,
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 @@ -834,6 +858,22 @@ 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",
Comment thread
banrahan marked this conversation as resolved.
folderName, err,
)
}
// Compute display path for created folder (used in nextstep).
// Only show cd hint for brand-new projects, not when adding
// a template subfolder to an existing project.
var folderDisplay string
if newlyCreated && !existingProject {
folderDisplay = filepath.ToSlash(folderName)
}

// Search for an agent manifest in the scaffolded project
cwd, err := os.Getwd()
if err != nil {
Expand All @@ -847,7 +887,9 @@ 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, ".", folderDisplay,
); err != nil {
if exterrors.IsCancellation(err) {
return exterrors.Cancelled("initialization was cancelled")
}
Expand All @@ -858,9 +900,21 @@ 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)
var folderDisplay string
if newlyCreated && !existingProject {
folderDisplay = filepath.ToSlash(folderName)
}
flags.manifestPointer = selectedTemplate.Source
if err := runInitFromManifest(ctx, flags, azdClient, httpClient); err != nil {
if err := runInitFromManifest(
ctx, flags, azdClient, httpClient, folderName, folderDisplay,
); err != nil {
if exterrors.IsCancellation(err) {
return exterrors.Cancelled("initialization was cancelled")
}
Expand Down Expand Up @@ -1093,21 +1147,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 @@ -1134,6 +1204,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 @@ -2208,7 +2289,11 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa
// everything is configured. All paths append the deploy hint as the
// trailing line. State-assembly errors are intentionally ignored: the
// resolver degrades gracefully on partial state per the design spec.
state, _ := nextstep.AssembleState(ctx, a.azdClient)
var stateOpts []nextstep.Option
if a.createdFolderDisplay != "" {
stateOpts = append(stateOpts, nextstep.WithCreatedFolder(a.createdFolderDisplay))
}
state, _ := nextstep.AssembleState(ctx, a.azdClient, stateOpts...)
_ = printAllNextIfTerminal(os.Stdout, nextstep.ResolveAfterInit(state))
return nil
}
Expand Down Expand Up @@ -3116,3 +3201,21 @@ func validateCodeDeployInput(noPrompt bool, deployMode, runtime, entryPoint, dep
}
return 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 @@ -85,7 +85,7 @@ func runReuseDefinition(
displayPath, def.Name,
))

projectConfig, err := ensureProject(ctx, flags, azdClient)
projectConfig, err := ensureProject(ctx, flags, azdClient, ".")
if err != nil {
return err
}
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