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 ed07552fe44..92efe7d3f48 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -95,6 +95,12 @@ type InitAction struct { 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 + + // userProvidedManifest is true when the init flow is driven by a manifest — + // either explicitly via the -m flag/positional argument, or when the user + // interactively selects a template that resolves to a manifest. When true, + // the init flow applies opinionated defaults to minimize interactive prompts. + userProvidedManifest bool } // modelSelector encapsulates the dependencies needed for model selection and @@ -265,6 +271,54 @@ func resolveInitAgentName( return validateInitAgentName(agentName) } +// resolveAgentNameFromManifestPointer resolves the agent name BEFORE any +// project folder is created so the project folder, the agent identity, the +// service entry, the src subfolder, and the post-init "cd" hint all use the +// same name. +// +// Resolution order: +// - If --agent-name is set, that value is used (validated) and pinned. +// - Else, peek the manifest's `name` field to seed the prompt default. +// If peek succeeds, prompt the user (or use the default in --no-prompt mode) +// and pin the result. +// - Else (peek failed: unsupported URL form, parse error, missing name), +// return "" so the caller falls back to today's defer behavior, which +// leaves name resolution to the inner downloadAgentYaml flow once the +// fully-loaded manifest is available. +// +// When a name is resolved, flags.agentName is set so the inner +// resolveInitAgentName call short-circuits without re-prompting the user. +func resolveAgentNameFromManifestPointer( + ctx context.Context, + azdClient *azdext.AzdClient, + flags *initFlags, + manifestPointer string, + httpClient *http.Client, +) (string, error) { + if flags.agentName != "" { + validated, err := validateInitAgentName(flags.agentName) + if err != nil { + return "", err + } + flags.agentName = validated + return validated, nil + } + + peeked := peekManifestName(ctx, manifestPointer, httpClient) + if peeked == "" { + // Defer to the inner flow which has access to the fully-loaded manifest. + return "", nil + } + + resolved, err := resolveInitAgentName(ctx, azdClient, flags, peeked) + if err != nil { + return "", err + } + // Pin so the inner resolveInitAgentName call is a no-op. + flags.agentName = resolved + return resolved, nil +} + func validateInitAgentName(name string) (string, error) { name = strings.TrimSpace(name) if err := agent_yaml.ValidateAgentName(name); err != nil { @@ -307,6 +361,39 @@ func setAgentNameOnTemplate(agentManifest *agent_yaml.AgentManifest, agentName s return nil } +// absolutizeRelativeManifestPaths converts the -m manifest pointer to absolute +// when it refers to a local path so it remains valid after ensureProject +// changes into a newly created project directory. URLs and already-absolute +// paths are left unchanged. Errors here are surfaced because they indicate a +// problem the user can fix (e.g. invalid pathname). +// +// Note: flags.src is intentionally left unchanged. It is the output target +// for the downloaded agent definition (defaults to src/ inside the +// project). InitAction.Run rewrites absolute --src values relative to the +// project root via filepath.Rel; converting a user-supplied relative --src +// to absolute before ensureProject changes into the new project folder would +// cause that rewrite to produce a "..\" path that escapes the project +// directory. +func absolutizeRelativeManifestPaths(flags *initFlags) error { + if flags.manifestPointer == "" { + return nil + } + if strings.HasPrefix(flags.manifestPointer, "http://") || + strings.HasPrefix(flags.manifestPointer, "https://") { + return nil + } + if filepath.IsAbs(flags.manifestPointer) { + return nil + } + + abs, err := filepath.Abs(flags.manifestPointer) + if err != nil { + return fmt.Errorf("resolve manifest path: %w", err) + } + flags.manifestPointer = abs + return nil +} + func folderNameStrippingParenSuffix(title string) string { if idx := strings.IndexByte(title, '('); idx >= 0 { title = strings.TrimSpace(title[:idx]) @@ -314,6 +401,160 @@ func folderNameStrippingParenSuffix(title string) string { return sanitizeAgentName(title) } +// peekManifestName makes a best-effort attempt to read just the top-level +// "name" field from an agent manifest at the given pointer. It is used by the +// -m flow to derive a project folder name before the full manifest is loaded +// inside InitAction.Run. Any failure (read error, parse error, missing name, +// unsupported pointer type) returns an empty string, leaving the caller to +// choose a conservative fallback. Errors are logged at debug level only — the +// authoritative download/parse happens later in downloadAgentYaml and surfaces +// the real diagnostic to the user. +// +// Supported pointer types: +// - local file paths (read via os.ReadFile) +// - GitHub URLs in the form recognized by parseGitHubUrlNaive (fetched via +// plain HTTP against the contents API, no `gh` CLI required) +// +// Other URL forms (private GitHub repos that need `gh` auth, non-GitHub URLs) +// return "" — the caller falls back to not creating a subdirectory in that +// case. +func peekManifestName(ctx context.Context, manifestPointer string, httpClient *http.Client) string { + if manifestPointer == "" { + return "" + } + + content, ok := readManifestContentForPeek(ctx, manifestPointer, httpClient) + if !ok { + return "" + } + + var head struct { + Name string `yaml:"name"` + } + if err := yaml.Unmarshal(content, &head); err != nil { + log.Printf("peek manifest name: parse: %v", err) + return "" + } + return strings.TrimSpace(head.Name) +} + +// readManifestContentForPeek returns the raw manifest bytes for a best-effort +// peek. It mirrors the local-file and naive-GitHub-URL fast paths of +// downloadAgentYaml without invoking the `gh` CLI. Returns ok=false on any +// failure or unsupported pointer type. +func readManifestContentForPeek( + ctx context.Context, manifestPointer string, httpClient *http.Client, +) ([]byte, bool) { + // Local file path: bypass URL handling entirely so a relative path like + // "agent.yaml" that happens to look URL-ish is still read from disk. + if !strings.HasPrefix(manifestPointer, "http://") && !strings.HasPrefix(manifestPointer, "https://") { + info, statErr := os.Stat(manifestPointer) + if statErr != nil || info.IsDir() { + return nil, false + } + //nolint:gosec // manifest path is an explicit user-provided local path; same trust model as downloadAgentYaml + content, err := os.ReadFile(manifestPointer) + if err != nil { + log.Printf("peek manifest name: read %s: %v", manifestPointer, err) + return nil, false + } + return content, true + } + + // GitHub URL: try the same naive parse + unauthenticated HTTP GET used + // inside downloadAgentYaml for public repositories. + urlInfo := parseGitHubUrlNaive(manifestPointer) + if urlInfo == nil { + return nil, false + } + if httpClient == nil { + return nil, false + } + + fileApiUrl := fmt.Sprintf("https://api.github.com/repos/%s/contents/%s", urlInfo.RepoSlug, urlInfo.FilePath) + if urlInfo.Branch != "" { + fileApiUrl += "?ref=" + url.QueryEscape(urlInfo.Branch) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, fileApiUrl, nil) + if err != nil { + log.Printf("peek manifest name: request: %v", err) + return nil, false + } + req.Header.Set("Accept", "application/vnd.github.v3.raw") + + //nolint:gosec // URL is constrained to the GitHub contents API built from a parsed GitHub URL + resp, err := httpClient.Do(req) + if err != nil { + log.Printf("peek manifest name: http: %v", err) + return nil, false + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + log.Printf("peek manifest name: http status %d", resp.StatusCode) + return nil, false + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + log.Printf("peek manifest name: read body: %v", err) + return nil, false + } + return body, true +} + +// parseGitHubUrlNaive mirrors (*InitAction).parseGitHubUrlNaive for callers +// that do not have an InitAction available (e.g. peekManifestName, which runs +// before the action is constructed). The receiver-bound version is kept in +// place so the existing downloadAgentYaml code path is untouched. +func parseGitHubUrlNaive(manifestPointer string) *GitHubUrlInfo { + parsedURL, err := url.Parse(manifestPointer) + if err != nil { + return nil + } + + if parsedURL.Host == "github.com" && strings.Contains(parsedURL.Path, "/blob/") { + parts := strings.SplitN(parsedURL.Path, "/blob/", 2) + if len(parts) != 2 { + return nil + } + repoSlug := strings.TrimPrefix(parts[0], "/") + branch, filePath, ok := strings.Cut(parts[1], "/") + if !ok || strings.Contains(branch, "/") { + return nil + } + return &GitHubUrlInfo{ + RepoSlug: repoSlug, + Branch: branch, + FilePath: filePath, + Hostname: "github.com", + } + } + + if parsedURL.Host == "raw.githubusercontent.com" { + pathPart := strings.TrimPrefix(parsedURL.Path, "/") + parts := strings.SplitN(pathPart, "/", 3) + if len(parts) < 3 { + return nil + } + repoSlug := parts[0] + "/" + parts[1] + if rest, ok := strings.CutPrefix(parts[2], "refs/heads/"); ok { + branch, filePath, ok := strings.Cut(rest, "/") + if !ok || strings.Contains(branch, "/") { + return nil + } + return &GitHubUrlInfo{ + RepoSlug: repoSlug, + Branch: branch, + FilePath: filePath, + Hostname: "github.com", + } + } + } + + return nil +} + func updateAgentDefinition( template any, update func(*agent_yaml.AgentDefinition), @@ -542,6 +783,7 @@ func runInitFromManifest( httpClient *http.Client, targetDir string, createdFolderDisplay string, + userProvidedManifest bool, ) error { // Ensure project and environment exist (no subscription/location prompting yet) projectConfig, err := ensureProject(ctx, flags, azdClient, targetDir) @@ -603,6 +845,7 @@ func runInitFromManifest( flags: flags, httpClient: httpClient, createdFolderDisplay: createdFolderDisplay, + userProvidedManifest: userProvidedManifest, } return action.Run(ctx) @@ -654,6 +897,11 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } } + // Capture whether the user explicitly provided a manifest (via -m flag + // or positional argument) BEFORE the auto-detection logic below may also + // set flags.manifestPointer. This drives the opinionated-defaults path. + userProvidedManifest := flags.manifestPointer != "" + ctx := azdext.WithAccessToken(cmd.Context()) azdClient, err := azdext.NewAzdClient() @@ -781,7 +1029,50 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, return err } - if err := runInitFromManifest(ctx, flags, azdClient, httpClient, ".", ""); err != nil { + // Resolve the agent name BEFORE creating the project folder + // so the folder, the agent identity, the service entry, and + // the cd hint all use the same user-chosen name. Peeking the + // manifest seeds the prompt default; an explicit --agent-name + // flag wins outright. See resolveAgentNameFromManifestPointer. + resolvedName, err := resolveAgentNameFromManifestPointer( + ctx, azdClient, flags, flags.manifestPointer, httpClient, + ) + if err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("initialization was cancelled") + } + return err + } + + // Mirror the template flow (#8210) and create a project folder + // derived from the resolved agent name. When the peek failed + // AND no --agent-name was provided (resolvedName == ""), fall + // back to the prior behavior of initializing in the current + // directory so we never leave the user with an empty folder + + // starter project after a downloadAgentYaml failure. + targetDir := "." + var folderDisplay string + if resolvedName != "" { + folderName := sanitizeAgentName(resolvedName) + // Make a local relative manifest path absolute before + // ensureProject changes into the new project directory, + // otherwise downloadAgentYaml will look for the manifest + // in the wrong place. flags.src is left as-is (see + // absolutizeRelativeManifestPaths comment for why). + if err := absolutizeRelativeManifestPaths(flags); err != nil { + return err + } + _, statErr := os.Stat(folderName) + newlyCreated := errors.Is(statErr, fs.ErrNotExist) + targetDir = folderName + if newlyCreated && !existingProject { + folderDisplay = filepath.ToSlash(folderName) + } + } + + if err := runInitFromManifest( + ctx, flags, azdClient, httpClient, targetDir, folderDisplay, userProvidedManifest, + ); err != nil { if exterrors.IsCancellation(err) { return exterrors.Cancelled("initialization was cancelled") } @@ -889,7 +1180,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, if manifestPath != "" { flags.manifestPointer = manifestPath if err := runInitFromManifest( - ctx, flags, azdClient, httpClient, ".", folderDisplay, + ctx, flags, azdClient, httpClient, ".", folderDisplay, true, ); err != nil { if exterrors.IsCancellation(err) { return exterrors.Cancelled("initialization was cancelled") @@ -902,8 +1193,31 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, default: // Agent manifest template - use existing -m flow. - // Create project in a new subdirectory derived from the template title. + flags.manifestPointer = selectedTemplate.Source + + // Resolve the agent name BEFORE creating the project + // folder so the folder, the agent identity, the service + // entry, and the cd hint all use the same user-chosen + // name. Peeking the template's manifest seeds the prompt + // default; --agent-name wins outright. + resolvedName, err := resolveAgentNameFromManifestPointer( + ctx, azdClient, flags, selectedTemplate.Source, httpClient, + ) + if err != nil { + if exterrors.IsCancellation(err) { + return exterrors.Cancelled("initialization was cancelled") + } + return err + } + + // Prefer the resolved agent name for the project folder. + // Fall back to the template title only when manifest peek + // failed (e.g. unsupported URL form) AND no --agent-name + // was provided. folderName := folderNameStrippingParenSuffix(selectedTemplate.Title) + if resolvedName != "" { + folderName = sanitizeAgentName(resolvedName) + } // Check whether the target directory already exists so we // only report "created" when a new directory was made. _, statErr := os.Stat(folderName) @@ -912,9 +1226,8 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, if newlyCreated && !existingProject { folderDisplay = filepath.ToSlash(folderName) } - flags.manifestPointer = selectedTemplate.Source if err := runInitFromManifest( - ctx, flags, azdClient, httpClient, folderName, folderDisplay, + ctx, flags, azdClient, httpClient, folderName, folderDisplay, true, ); err != nil { if exterrors.IsCancellation(err) { return exterrors.Cancelled("initialization was cancelled") @@ -1040,7 +1353,7 @@ func (a *InitAction) Run(ctx context.Context) error { // Code deploy is supported for Python and .NET projects. if _, ok := agentManifest.Template.(agent_yaml.ContainerAgent); ok { showCodeDeploy := isPythonProject(targetDir) || isDotnetProject(targetDir) - deployMode, err := promptDeployMode(ctx, a.azdClient, a.flags.noPrompt, showCodeDeploy, a.flags.deployMode) + deployMode, err := promptDeployMode(ctx, a.azdClient, a.flags.noPrompt, showCodeDeploy, a.flags.deployMode, a.userProvidedManifest) if err != nil { return fmt.Errorf("prompting for deploy mode: %w", err) } @@ -1052,7 +1365,7 @@ func (a *InitAction) Run(ctx context.Context) error { runtime: a.flags.runtime, entryPoint: a.flags.entryPoint, depResolution: a.flags.depResolution, - }) + }, a.userProvidedManifest) if err != nil { return fmt.Errorf("prompting for code configuration: %w", err) } @@ -1950,7 +2263,7 @@ func (a *InitAction) downloadAgentYaml( var contentStr string // First try naive parsing assuming branch is a single word. This allows users to not have to authenticate // with gh CLI for public repositories. - urlInfo = a.parseGitHubUrlNaive(manifestPointer) + urlInfo = parseGitHubUrlNaive(manifestPointer) if urlInfo != nil { // Construct GitHub Contents API URL with ref query parameter fileApiUrl := fmt.Sprintf("https://api.github.com/repos/%s/contents/%s", urlInfo.RepoSlug, urlInfo.FilePath) @@ -2164,7 +2477,7 @@ func writeAgentDefinitionFile(targetDir string, agentManifest *agent_yaml.AgentM return fmt.Errorf("saving file to %s: %w", filePath, err) } - fmt.Println(output.WithGrayFormat("Processed agent.yaml at %s", filePath)) + log.Printf("Processed agent.yaml at %s", filePath) // Generate .agentignore if it doesn't already exist agentIgnorePath := filepath.Join(targetDir, ".agentignore") @@ -2568,14 +2881,6 @@ func (a *InitAction) populateContainerSettings( ctx context.Context, manifestResources *agent_yaml.ContainerResources, ) (*project.ContainerSettings, error) { - choices := make([]*azdext.SelectChoice, len(project.ResourceTiers)) - for i, t := range project.ResourceTiers { - choices[i] = &azdext.SelectChoice{ - Label: t.String(), - Value: fmt.Sprintf("%d", i), - } - } - defaultIndex := int32(0) if manifestResources != nil { for i, t := range project.ResourceTiers { @@ -2586,6 +2891,29 @@ func (a *InitAction) populateContainerSettings( } } + // When the user provided a manifest explicitly (-m), auto-select the default + // resource tier without prompting to minimize interactive steps. In the + // primary -m quickstart path (Python/.NET), deploy mode auto-selects + // "container" so this function is reached for the default flow. + if a.userProvidedManifest { + selected := project.ResourceTiers[defaultIndex] + log.Printf("Defaulted compute tier: %s", selected.String()) + return &project.ContainerSettings{ + Resources: &project.ResourceSettings{ + Memory: selected.Memory, + Cpu: selected.Cpu, + }, + }, nil + } + + choices := make([]*azdext.SelectChoice, len(project.ResourceTiers)) + for i, t := range project.ResourceTiers { + choices[i] = &azdext.SelectChoice{ + Label: t.String(), + Value: fmt.Sprintf("%d", i), + } + } + resp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ Options: &azdext.SelectOptions{ Message: "Select resources (CPU and Memory) for your agent. You can adjust these settings later in the azure.yaml file if needed.", @@ -2624,88 +2952,6 @@ func downloadGithubManifest( return content, nil } -// parseGitHubUrlNaive attempts to parse a GitHub URL assuming a simple single-word branch name. -// Returns nil if the URL doesn't match the expected pattern. -// Expected formats: -// - https://github.com/{owner}/{repo}/blob/{branch}/{path} -// - https://raw.githubusercontent.com/{owner}/{repo}/refs/heads/{branch}/{path} -func (a *InitAction) parseGitHubUrlNaive(manifestPointer string) *GitHubUrlInfo { - // Parse URL to properly handle query parameters and fragments - parsedURL, err := url.Parse(manifestPointer) - if err != nil { - return nil - } - - // Try parsing github.com/blob format: https://github.com/{owner}/{repo}/blob/{branch}/{path} - if parsedURL.Host == "github.com" && strings.Contains(parsedURL.Path, "/blob/") { - hostname := "github.com" - - // Split by /blob/ - parts := strings.SplitN(parsedURL.Path, "/blob/", 2) - if len(parts) != 2 { - return nil - } - - // Extract repo slug (owner/repo) from the first part - repoPath := strings.TrimPrefix(parts[0], "/") - repoSlug := repoPath - - branch, filePath, ok := strings.Cut(parts[1], "/") - if !ok { - return nil - } - - // Only use naive parsing if branch looks like a simple single word (no slashes) - if strings.Contains(branch, "/") { - return nil - } - - return &GitHubUrlInfo{ - RepoSlug: repoSlug, - Branch: branch, - FilePath: filePath, - Hostname: hostname, - } - } - - // Try parsing raw.githubusercontent.com format: https://raw.githubusercontent.com/{owner}/{repo}/refs/heads/{branch}/{path} - if parsedURL.Host == "raw.githubusercontent.com" { - hostname := "github.com" // API calls still use github.com - - // Remove leading slash from path - pathPart := strings.TrimPrefix(parsedURL.Path, "/") - - // Split path: {owner}/{repo}/refs/heads/{branch}/{file-path} - parts := strings.SplitN(pathPart, "/", 3) // owner, repo, rest - if len(parts) < 3 { - return nil - } - - repoSlug := parts[0] + "/" + parts[1] - rest := parts[2] - if rest, ok := strings.CutPrefix(rest, "refs/heads/"); ok { - branch, filePath, ok := strings.Cut(rest, "/") - if !ok { - return nil - } - - // Only use naive parsing if branch looks like a simple single word - if strings.Contains(branch, "/") { - return nil - } - - return &GitHubUrlInfo{ - RepoSlug: repoSlug, - Branch: branch, - FilePath: filePath, - Hostname: hostname, - } - } - } - - return nil -} - // parseGitHubUrl extracts repository information from various GitHub URL formats using extension framework func (a *InitAction) parseGitHubUrl(ctx context.Context, manifestPointer string) (*GitHubUrlInfo, error) { urlInfo, err := a.azdClient.Project().ParseGitHubUrl(ctx, &azdext.ParseGitHubUrlRequest{ 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 1a80590966f..822f1048749 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 @@ -613,7 +613,7 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) srcDir, _ = os.Getwd() } showCodeDeploy := isPythonProject(srcDir) || isDotnetProject(srcDir) - deployMode, err := promptDeployMode(ctx, a.azdClient, a.flags.noPrompt, showCodeDeploy, a.flags.deployMode) + deployMode, err := promptDeployMode(ctx, a.azdClient, a.flags.noPrompt, showCodeDeploy, a.flags.deployMode, false) if err != nil { return nil, err } @@ -970,7 +970,9 @@ func (a *InitFromCodeAction) resolveSelectedModelDeployment( flags: a.flags, } - return selector.getModelDetails(ctx, model.Name) + // allowSkip=false: in this recovery path the user already explicitly chose + // the model via selectNewModel earlier, so offering "Skip" would be confusing. + return selector.getModelDetails(ctx, model.Name, false) } // appendEnvVar appends an environment variable to a possibly-nil slice pointer, @@ -1200,7 +1202,7 @@ func (a *InitFromCodeAction) promptCodeConfiguration(ctx context.Context, srcDir runtime: a.flags.runtime, entryPoint: a.flags.entryPoint, depResolution: a.flags.depResolution, - }) + }, false) } // protocolInfo pairs a protocol name with the default version used when generating agent.yaml. @@ -1330,7 +1332,15 @@ func knownProtocolNames() string { // When deployModeFlag is set, it is used directly (for --no-prompt with explicit flag). // When noPrompt is true and no flag is provided, defaults to "container" for backward compatibility. // When showCodeDeploy is false and no explicit flag overrides, code deploy is not offered. -func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt bool, showCodeDeploy bool, deployModeFlag string) (string, error) { +func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt bool, showCodeDeploy bool, deployModeFlag string, userProvidedManifest bool) (string, error) { + // Resolution precedence: + // 1. Explicit flag (--deploy-mode) — always wins + // 2. !showCodeDeploy — container is the only option (not Python/.NET) + // 3. userProvidedManifest — auto-select "container" (opinionated default; + // triggered by -m flag OR interactive template selection) + // 4. noPrompt — "container" for backward compatibility (no signal) + // 5. Interactive prompt + // Explicit flag takes precedence if deployModeFlag != "" { switch deployModeFlag { @@ -1349,6 +1359,14 @@ func promptDeployMode(ctx context.Context, azdClient *azdext.AzdClient, noPrompt return "container", nil } + // When the user provided a manifest explicitly (-m), auto-select the + // opinionated default (container) without prompting. Users who want + // code deploy with -m can pass --deploy-mode code explicitly. + if userProvidedManifest { + log.Printf("Auto-selected deploy mode: container (use --deploy-mode code for code deploy)") + return "container", nil + } + if noPrompt { return "container", nil } @@ -1437,7 +1455,7 @@ type codeDeployOptions struct { // promptCodeConfig prompts for code deploy configuration (runtime, entry point, // dependency resolution). When noPrompt is true, flags or defaults are used without prompting. -func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir string, noPrompt bool, opts codeDeployOptions) (*agent_yaml.CodeConfiguration, error) { +func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir string, noPrompt bool, opts codeDeployOptions, userProvidedManifest bool) (*agent_yaml.CodeConfiguration, error) { if srcDir == "" { srcDir = "." } @@ -1468,12 +1486,13 @@ func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir s var runtime string if opts.runtime != "" { runtime = opts.runtime - } else if noPrompt { + } else if noPrompt || userProvidedManifest { if isDotnet && !isPython { runtime = "dotnet_10" } else { - runtime = "python_3_13" // default to Python for mixed/unknown repos (language preference, not version compat) + runtime = "python_3_13" } + log.Printf("Auto-detected runtime: %s", runtime) } else { defaultIdx := int32(0) // First item in the filtered list runtimeResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ @@ -1498,8 +1517,9 @@ func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir s var entryPoint string if opts.entryPoint != "" { entryPoint = opts.entryPoint - } else if noPrompt { + } else if noPrompt || userProvidedManifest { entryPoint = defaultEntryPoint + log.Printf("Auto-detected entry point: %s", entryPoint) } else { entryPointResp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ Options: &azdext.PromptOptions{ @@ -1525,8 +1545,9 @@ func promptCodeConfig(ctx context.Context, azdClient *azdext.AzdClient, srcDir s var depResolution string if opts.depResolution != "" { depResolution = opts.depResolution - } else if noPrompt { + } else if noPrompt || userProvidedManifest { depResolution = "remote_build" + log.Printf("Defaulted dependency resolution to remote_build") } else { depDefaultIdx := int32(0) depResResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ 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 8ee9ec3a1a7..9384ea2623e 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 @@ -892,13 +892,14 @@ func TestPromptDeployMode_FlagOverride(t *testing.T) { t.Parallel() tests := []struct { - name string - noPrompt bool - showCodeDeploy bool - flag string - want string - wantErr bool - wantErrContain string + name string + noPrompt bool + showCodeDeploy bool + flag string + userProvidedManifest bool + want string + wantErr bool + wantErrContain string }{ { name: "flag=container returns container", @@ -943,12 +944,36 @@ func TestPromptDeployMode_FlagOverride(t *testing.T) { flag: "", want: "container", }, + { + name: "userProvidedManifest + showCodeDeploy auto-selects container", + noPrompt: false, + showCodeDeploy: true, + flag: "", + userProvidedManifest: true, + want: "container", + }, + { + name: "showCodeDeploy=false returns container regardless of userProvidedManifest", + noPrompt: false, + showCodeDeploy: false, + flag: "", + userProvidedManifest: true, + want: "container", + }, + { + name: "explicit flag overrides userProvidedManifest", + noPrompt: false, + showCodeDeploy: true, + flag: "container", + userProvidedManifest: true, + want: "container", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - got, err := promptDeployMode(t.Context(), nil, tt.noPrompt, tt.showCodeDeploy, tt.flag) + got, err := promptDeployMode(t.Context(), nil, tt.noPrompt, tt.showCodeDeploy, tt.flag, tt.userProvidedManifest) if tt.wantErr { if err == nil { t.Fatal("expected error, got nil") @@ -972,13 +997,14 @@ func TestPromptCodeConfig_FlagOverrides(t *testing.T) { t.Parallel() tests := []struct { - name string - files []string // files to create in temp dir - noPrompt bool - opts codeDeployOptions - wantRuntime string - wantEntry string - wantDepRes string + name string + files []string // files to create in temp dir + noPrompt bool + userProvidedManifest bool + opts codeDeployOptions + wantRuntime string + wantEntry string + wantDepRes string }{ { name: "all opts provided", @@ -1024,6 +1050,36 @@ func TestPromptCodeConfig_FlagOverrides(t *testing.T) { wantEntry: "app.py", wantDepRes: "remote_build", }, + { + name: "userProvidedManifest auto-detects python defaults", + files: []string{"requirements.txt", "app.py"}, + noPrompt: false, + userProvidedManifest: true, + opts: codeDeployOptions{}, + wantRuntime: "python_3_13", + wantEntry: "app.py", + wantDepRes: "remote_build", + }, + { + name: "userProvidedManifest auto-detects dotnet defaults", + files: []string{"MyAgent.csproj"}, + noPrompt: false, + userProvidedManifest: true, + opts: codeDeployOptions{}, + wantRuntime: "dotnet_10", + wantEntry: "MyAgent.dll", + wantDepRes: "remote_build", + }, + { + name: "opts override userProvidedManifest defaults", + files: []string{"requirements.txt", "app.py"}, + noPrompt: false, + userProvidedManifest: true, + opts: codeDeployOptions{runtime: "python_3_14", entryPoint: "bot.py", depResolution: "bundled"}, + wantRuntime: "python_3_14", + wantEntry: "bot.py", + wantDepRes: "bundled", + }, } for _, tt := range tests { @@ -1036,7 +1092,7 @@ func TestPromptCodeConfig_FlagOverrides(t *testing.T) { } } - got, err := promptCodeConfig(t.Context(), nil, dir, tt.noPrompt, tt.opts) + got, err := promptCodeConfig(t.Context(), nil, dir, tt.noPrompt, tt.opts, tt.userProvidedManifest) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go index ee028907f6c..9e6178a2e20 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_models.go @@ -5,6 +5,7 @@ package cmd import ( "context" + "errors" "fmt" "log" "slices" @@ -26,6 +27,13 @@ import ( var defaultSkuPriority = []string{"GlobalStandard", "DataZoneStandard", "Standard"} +// errModelSkipped is a sentinel error returned by getModelDetails when the +// user explicitly chooses "Skip this model" from the model-selection prompt. +// Callers MUST use errors.Is to detect this case and drop the model from the +// manifest rather than treating it as a failure. The resource is removed +// from manifest.Resources in ProcessModels so no deployment is provisioned. +var errModelSkipped = errors.New("user skipped model") + func (a *modelSelector) loadAiCatalog(ctx context.Context) error { if a.modelCatalog != nil { return nil @@ -199,36 +207,101 @@ func (a *InitAction) getModelDeploymentDetails( } if len(matchingDeployments) > 0 { - fmt.Printf("In your Microsoft Foundry project, found %d existing model deployment(s) matching your model %s.\n", len(matchingDeployments), model.Id) + // Build a deterministically-ordered list of matching deployment names + // so options, defaults, and --no-prompt selection are stable across runs. + sortedNames := make([]string, 0, len(matchingDeployments)) + for name := range matchingDeployments { + sortedNames = append(sortedNames, name) + } + slices.Sort(sortedNames) + + // In --no-prompt mode, auto-select the first matching deployment + // deterministically so headless/CI flows don't block on a prompt. + if a.flags.noPrompt { + name := sortedNames[0] + deployment := matchingDeployments[name] + log.Printf( + "--no-prompt: using existing model deployment '%s' (version: %s) for model '%s'", + name, deployment.Version, model.Id, + ) + return &project.Deployment{ + Name: name, + Model: project.DeploymentModel{ + Name: model.Id, + Format: deployment.ModelFormat, + Version: deployment.Version, + }, + Sku: project.DeploymentSku{ + Name: deployment.SkuName, + Capacity: deployment.SkuCapacity, + }, + }, false, nil + } - var options []string - for deploymentName := range matchingDeployments { - options = append(options, deploymentName) + // Interactive Use/Change/Skip-style selector that mirrors the + // standard manifest model prompt (init_models.go ~line 400). Each + // existing deployment becomes a "use:" option; "deploy_new" + // falls through to the new-deployment configuration path below; + // "skip" returns errModelSkipped so ProcessModels drops the model + // resource from the manifest. + choices := make([]*azdext.SelectChoice, 0, len(sortedNames)+2) + for _, name := range sortedNames { + d := matchingDeployments[name] + choices = append(choices, &azdext.SelectChoice{ + Value: "use:" + name, + Label: fmt.Sprintf("Use existing deployment '%s' (version: %s)", name, d.Version), + }) } - options = append(options, "Create new model deployment") + choices = append(choices, + &azdext.SelectChoice{Value: "deploy_new", Label: "Deploy a new model"}, + &azdext.SelectChoice{Value: "skip", Label: "Skip this model (do not deploy)"}, + ) - selection, err := a.selectFromList(ctx, "deployment", options, options[0]) + defaultIdx := int32(0) + resp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: fmt.Sprintf( + "Found %d existing deployment(s) for model '%s' in the selected Foundry project. How would you like to proceed?", + len(sortedNames), model.Id, + ), + Choices: choices, + SelectedIndex: &defaultIdx, + }, + }) if err != nil { + if exterrors.IsCancellation(err) { + return nil, false, exterrors.Cancelled("model deployment selection was cancelled") + } return nil, false, fmt.Errorf("failed to select deployment: %w", err) } - if selection != "Create new model deployment" { - fmt.Printf("Using existing model deployment: %s\n", selection) - - if deployment, exists := matchingDeployments[selection]; exists { - return &project.Deployment{ - Name: selection, - Model: project.DeploymentModel{ - Name: model.Id, - Format: deployment.ModelFormat, - Version: deployment.Version, - }, - Sku: project.DeploymentSku{ - Name: deployment.SkuName, - Capacity: deployment.SkuCapacity, - }, - }, false, nil - } + selected := choices[*resp.Value].Value + switch { + case selected == "skip": + fmt.Println(output.WithWarningFormat( + "Skipped model '%s'. The agent will not have a model deployed.", model.Id)) + fmt.Println(output.WithGrayFormat( + "Configure your agent's model manually before running 'azd provision'.")) + return nil, false, errModelSkipped + case selected == "deploy_new": + // Fall through to the deploy-new logic below. + case strings.HasPrefix(selected, "use:"): + name := strings.TrimPrefix(selected, "use:") + deployment := matchingDeployments[name] + log.Printf("Using existing model deployment '%s' (version: %s) for model '%s'", + name, deployment.Version, model.Id) + return &project.Deployment{ + Name: name, + Model: project.DeploymentModel{ + Name: model.Id, + Format: deployment.ModelFormat, + Version: deployment.Version, + }, + Sku: project.DeploymentSku{ + Name: deployment.SkuName, + Capacity: deployment.SkuCapacity, + }, + }, false, nil } } else { color.Yellow( @@ -237,7 +310,9 @@ func (a *InitAction) getModelDeploymentDetails( ) noMatchChoice := "deploy_new" - if !a.flags.noPrompt { + if a.userProvidedManifest { + log.Printf("Will deploy new model '%s' (no existing deployment found)", model.Id) + } else if !a.flags.noPrompt { noMatchChoices := []*azdext.SelectChoice{ { Label: fmt.Sprintf("Deploy a new '%s' model to the selected Foundry project", model.Id), @@ -308,8 +383,13 @@ func (a *InitAction) getModelDeploymentDetails( } } - modelDetails, err := a.getModelSelector().getModelDetails(ctx, model.Id) + modelDetails, err := a.getModelSelector().getModelDetails(ctx, model.Id, true) if err != nil { + if errors.Is(err, errModelSkipped) { + // Propagate the sentinel unwrapped so ProcessModels can detect + // the skip and drop the resource from manifest.Resources. + return nil, false, err + } return nil, false, fmt.Errorf("failed to get model details: %w", err) } @@ -345,7 +425,9 @@ func (a *InitAction) getModelDeploymentDetails( }, true, nil } -func (a *modelSelector) getModelDetails(ctx context.Context, modelName string) (*azdext.AiModelDeployment, error) { +func (a *modelSelector) getModelDetails( + ctx context.Context, modelName string, allowSkip bool, +) (*azdext.AiModelDeployment, error) { if err := a.loadAiCatalog(ctx); err != nil { return nil, err } @@ -361,11 +443,19 @@ func (a *modelSelector) getModelDetails(ctx context.Context, modelName string) ( } model = selectedModel } else if !a.flags.noPrompt { - // Model found in catalog — let user confirm or choose a different one + // Model found in catalog -- let user confirm, choose a different one, + // or (when allowed) skip the model entirely. This is the standard + // selector for both interactively-detected manifests and the -m flow. choices := []*azdext.SelectChoice{ {Label: fmt.Sprintf("Use '%s' (from manifest)", model.Name), Value: "keep"}, {Label: "Choose a different model", Value: "change"}, } + if allowSkip { + choices = append(choices, &azdext.SelectChoice{ + Label: "Skip this model (do not deploy)", + Value: "skip", + }) + } defaultIdx := int32(0) resp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ @@ -382,7 +472,8 @@ func (a *modelSelector) getModelDetails(ctx context.Context, modelName string) ( return nil, fmt.Errorf("failed to prompt for model choice: %w", err) } - if choices[*resp.Value].Value == "change" { + switch choices[*resp.Value].Value { + case "change": selectedModel, err := a.promptModelFromCatalog(ctx) if err != nil { return nil, fmt.Errorf("failed to select alternative model: %w", err) @@ -391,6 +482,12 @@ func (a *modelSelector) getModelDetails(ctx context.Context, modelName string) ( return nil, fmt.Errorf("no model selected, exiting") } model = selectedModel + case "skip": + fmt.Println(output.WithWarningFormat( + "Skipped model '%s'. The agent will not have a model deployed.", model.Name)) + fmt.Println(output.WithGrayFormat( + "Configure your agent's model manually before running 'azd provision'.")) + return nil, errModelSkipped } } @@ -857,6 +954,11 @@ func (a *InitAction) ProcessModels(ctx context.Context, manifest *agent_yaml.Age // branch was taken. anyModelProcessed := false anyNewDeployment := false + // skippedModelResources collects names of model resources the user + // explicitly chose to skip via the model-selection prompt. After the + // loop, these are dropped from manifest.Resources so no deployment is + // provisioned for them. + skippedModelResources := map[string]struct{}{} switch agentDef.Kind { case agent_yaml.AgentKindHosted: for _, resource := range manifest.Resources { @@ -875,6 +977,14 @@ func (a *InitAction) ProcessModels(ctx context.Context, manifest *agent_yaml.Age model := agent_yaml.Model{Id: resource.Id} modelDeployment, isNew, err := a.getModelDeploymentDetails(ctx, model) if err != nil { + if errors.Is(err, errModelSkipped) { + // User chose "Skip this model" in the selector. Drop + // the resource from manifest.Resources below so no + // deployment is provisioned. Don't touch the pending + // provision signal for this resource. + skippedModelResources[resource.Name] = struct{}{} + continue + } return nil, nil, fmt.Errorf("failed to get model deployment details: %w", err) } deploymentDetails = append(deploymentDetails, *modelDeployment) @@ -887,6 +997,20 @@ func (a *InitAction) ProcessModels(ctx context.Context, manifest *agent_yaml.Age } } + // Drop any model resources the user chose to skip so they aren't + // provisioned. Non-model resources and resources of other kinds are + // preserved unchanged. + if len(skippedModelResources) > 0 { + manifest.Resources = slices.DeleteFunc(manifest.Resources, func(r any) bool { + mr, ok := r.(agent_yaml.ModelResource) + if !ok { + return false + } + _, skipped := skippedModelResources[mr.Name] + return skipped + }) + } + updatedManifest, err := agent_yaml.InjectParameterValuesIntoManifest(manifest, paramValues) if err != nil { return nil, nil, fmt.Errorf("failed to inject deployment names into manifest: %w", err) @@ -912,7 +1036,7 @@ func (a *InitAction) ProcessModels(ctx context.Context, manifest *agent_yaml.Age log.Printf("warning: failed to update model_deployment provision signal: %v", err) } - fmt.Println("Model deployment details processed and injected into agent definition. Deployment details can also be found in the JSON formatted AI_PROJECT_DEPLOYMENTS environment variable.") + log.Println("Model deployment details processed and injected into agent definition. Deployment details can also be found in the JSON formatted AI_PROJECT_DEPLOYMENTS environment variable.") return updatedManifest, deploymentDetails, nil } 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 c4e4569fdac..1b1530821d9 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 @@ -724,8 +724,7 @@ func TestParseGitHubUrlNaive(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - a := &InitAction{} - result := a.parseGitHubUrlNaive(tt.url) + result := parseGitHubUrlNaive(tt.url) if tt.expected == nil { if result != nil { @@ -2584,3 +2583,359 @@ func TestFolderNameFromTitle(t *testing.T) { }) } } + +// --------------------------------------------------------------------------- +// peekManifestName: best-effort manifest name extraction for -m folder +// creation (covers PR review — parity with template-flow folder creation) +// --------------------------------------------------------------------------- + +func TestPeekManifestName_LocalFile_WithName(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + //nolint:gosec // test fixture file permissions are intentional + if err := os.WriteFile(path, []byte("name: my-cool-agent\ndescription: hi\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got := peekManifestName(t.Context(), path, &http.Client{}) + if got != "my-cool-agent" { + t.Errorf("peekManifestName = %q, want %q", got, "my-cool-agent") + } +} + +func TestPeekManifestName_LocalFile_WithoutName(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + //nolint:gosec // test fixture file permissions are intentional + if err := os.WriteFile(path, []byte("description: missing top-level name\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got := peekManifestName(t.Context(), path, &http.Client{}) + if got != "" { + t.Errorf("peekManifestName = %q, want empty", got) + } +} + +func TestPeekManifestName_LocalFile_EmptyName(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + //nolint:gosec // test fixture file permissions are intentional + if err := os.WriteFile(path, []byte("name: \" \"\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got := peekManifestName(t.Context(), path, &http.Client{}) + if got != "" { + t.Errorf("peekManifestName = %q, want empty (whitespace-only name)", got) + } +} + +func TestPeekManifestName_LocalFile_NonExistent(t *testing.T) { + t.Parallel() + got := peekManifestName(t.Context(), filepath.Join(t.TempDir(), "does-not-exist.yaml"), &http.Client{}) + if got != "" { + t.Errorf("peekManifestName = %q, want empty for missing file", got) + } +} + +func TestPeekManifestName_LocalFile_Directory(t *testing.T) { + t.Parallel() + // Pointing at a directory should not be treated as a manifest — full + // validation runs in checkNotDirectory; peek must not panic or return + // noise. + got := peekManifestName(t.Context(), t.TempDir(), &http.Client{}) + if got != "" { + t.Errorf("peekManifestName = %q, want empty for directory", got) + } +} + +func TestPeekManifestName_LocalFile_MalformedYAML(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + //nolint:gosec // test fixture file permissions are intentional + if err := os.WriteFile(path, []byte("name: [unterminated\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got := peekManifestName(t.Context(), path, &http.Client{}) + if got != "" { + t.Errorf("peekManifestName = %q, want empty for malformed yaml", got) + } +} + +func TestPeekManifestName_LocalFile_NestedFieldsIgnored(t *testing.T) { + t.Parallel() + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + manifest := "" + + "name: nested-agent\n" + + "description: hi\n" + + "tools:\n" + + " - name: not-the-agent-name\n" + + " - name: also-not-it\n" + + "environment:\n" + + " - name: SOME_ENV\n" + + " value: x\n" + //nolint:gosec // test fixture file permissions are intentional + if err := os.WriteFile(path, []byte(manifest), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + got := peekManifestName(t.Context(), path, &http.Client{}) + if got != "nested-agent" { + t.Errorf("peekManifestName = %q, want nested-agent (top-level only)", got) + } +} + +func TestPeekManifestName_EmptyPointer(t *testing.T) { + t.Parallel() + got := peekManifestName(t.Context(), "", &http.Client{}) + if got != "" { + t.Errorf("peekManifestName(\"\") = %q, want empty", got) + } +} + +// --------------------------------------------------------------------------- +// resolveAgentNameFromManifestPointer: validates that the agent name is +// resolved (via --agent-name flag or peek+default in noPrompt) BEFORE any +// project folder is created, so the folder / agent identity / service entry / +// src subfolder / cd hint all use the same name. +// --------------------------------------------------------------------------- + +func TestResolveAgentNameFromManifestPointer_FlagWinsWithoutPeek(t *testing.T) { + t.Parallel() + + flags := &initFlags{agentName: "flag-agent", noPrompt: true} + // Manifest pointer is intentionally unreachable to prove the flag short-circuits + // the peek entirely. + got, err := resolveAgentNameFromManifestPointer( + t.Context(), nil, flags, filepath.Join(t.TempDir(), "does-not-exist.yaml"), &http.Client{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "flag-agent" { + t.Errorf("got name = %q, want %q", got, "flag-agent") + } + if flags.agentName != "flag-agent" { + t.Errorf("flags.agentName = %q, want pinned to flag value", flags.agentName) + } +} + +func TestResolveAgentNameFromManifestPointer_NoPromptUsesPeekedDefault(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + //nolint:gosec // test fixture file permissions are intentional + if err := os.WriteFile(path, []byte("name: from-manifest\ndescription: hi\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + flags := &initFlags{noPrompt: true} + got, err := resolveAgentNameFromManifestPointer( + t.Context(), nil, flags, path, &http.Client{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "from-manifest" { + t.Errorf("got name = %q, want %q", got, "from-manifest") + } + if flags.agentName != "from-manifest" { + t.Errorf("flags.agentName = %q, want pinned to peeked default", flags.agentName) + } +} + +func TestResolveAgentNameFromManifestPointer_PeekFailsReturnsEmpty(t *testing.T) { + t.Parallel() + + // Peek will fail (nonexistent local path, no flag). Helper must return "" + // and leave flags.agentName empty so the caller falls back to the deferred + // inner resolution. + flags := &initFlags{noPrompt: true} + got, err := resolveAgentNameFromManifestPointer( + t.Context(), nil, flags, filepath.Join(t.TempDir(), "does-not-exist.yaml"), &http.Client{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Errorf("got name = %q, want empty when peek fails and no flag", got) + } + if flags.agentName != "" { + t.Errorf("flags.agentName = %q, want unchanged when peek fails", flags.agentName) + } +} + +func TestResolveAgentNameFromManifestPointer_InvalidFlagReturnsError(t *testing.T) { + t.Parallel() + + // Invalid flag value must surface a validation error rather than silently + // falling back to peek — the user explicitly asked for this name. + flags := &initFlags{agentName: "INVALID NAME with spaces", noPrompt: true} + _, err := resolveAgentNameFromManifestPointer( + t.Context(), nil, flags, filepath.Join(t.TempDir(), "ignored.yaml"), &http.Client{}, + ) + if err == nil { + t.Fatalf("expected validation error for invalid --agent-name, got nil") + } +} + +func TestResolveAgentNameFromManifestPointer_FlagPeekConsistency(t *testing.T) { + t.Parallel() + + // When --agent-name matches what peek would return, the resolved name and + // the flag value should agree and the manifest never needs to be read. + dir := t.TempDir() + path := filepath.Join(dir, "agent.yaml") + //nolint:gosec // test fixture file permissions are intentional + if err := os.WriteFile(path, []byte("name: shared-name\n"), 0o644); err != nil { + t.Fatalf("WriteFile: %v", err) + } + + flags := &initFlags{agentName: "shared-name", noPrompt: true} + got, err := resolveAgentNameFromManifestPointer( + t.Context(), nil, flags, path, &http.Client{}, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "shared-name" { + t.Errorf("got name = %q, want %q", got, "shared-name") + } +} + +func TestPeekManifestName_NonGitHubURL(t *testing.T) { + t.Parallel() + // Non-GitHub URLs are unsupported by the naive peek path and must return + // "" (the caller then falls back to targetDir="."). + got := peekManifestName(t.Context(), "https://example.com/some/manifest.yaml", &http.Client{}) + if got != "" { + t.Errorf("peekManifestName = %q, want empty for non-GitHub URL", got) + } +} + +// --------------------------------------------------------------------------- +// absolutizeRelativeManifestPaths: ensures the -m manifest path survives +// ensureProject chdir into the newly created project directory. flags.src +// is intentionally NOT absolutized (see godoc on the helper for why). +// --------------------------------------------------------------------------- + +func TestAbsolutizeRelativeManifestPaths_RelativeLocalManifest(t *testing.T) { + tmp := t.TempDir() + t.Chdir(tmp) + + flags := &initFlags{ + manifestPointer: "agent.yaml", + src: "src", + } + if err := absolutizeRelativeManifestPaths(flags); err != nil { + t.Fatalf("absolutizeRelativeManifestPaths: %v", err) + } + if !filepath.IsAbs(flags.manifestPointer) { + t.Errorf("manifestPointer should be absolute, got %q", flags.manifestPointer) + } + // Regression guard: --src is an output target (where the agent + // definition is downloaded to, relative to the project root). + // Absolutizing it before ensureProject chdirs into the new project + // folder would cause InitAction.Run's filepath.Rel rewrite to produce + // "..\src", writing the agent definition outside the new project. + if flags.src != "src" { + t.Errorf("src should remain relative %q, got %q", "src", flags.src) + } +} + +func TestAbsolutizeRelativeManifestPaths_AbsoluteLocalUnchanged(t *testing.T) { + t.Parallel() + absManifest := filepath.Join(t.TempDir(), "agent.yaml") + + flags := &initFlags{ + manifestPointer: absManifest, + src: "src", + } + if err := absolutizeRelativeManifestPaths(flags); err != nil { + t.Fatalf("absolutizeRelativeManifestPaths: %v", err) + } + if flags.manifestPointer != absManifest { + t.Errorf("absolute manifestPointer should be unchanged, got %q", flags.manifestPointer) + } + if flags.src != "src" { + t.Errorf("src should be unchanged, got %q", flags.src) + } +} + +func TestAbsolutizeRelativeManifestPaths_URLPointerUnchanged(t *testing.T) { + t.Parallel() + const ghURL = "https://github.com/owner/repo/blob/main/agent.yaml" + flags := &initFlags{ + manifestPointer: ghURL, + src: "", + } + if err := absolutizeRelativeManifestPaths(flags); err != nil { + t.Fatalf("absolutizeRelativeManifestPaths: %v", err) + } + if flags.manifestPointer != ghURL { + t.Errorf("URL manifestPointer should be unchanged, got %q", flags.manifestPointer) + } +} + +func TestAbsolutizeRelativeManifestPaths_EmptyFields(t *testing.T) { + t.Parallel() + flags := &initFlags{} + if err := absolutizeRelativeManifestPaths(flags); err != nil { + t.Fatalf("absolutizeRelativeManifestPaths: %v", err) + } + if flags.manifestPointer != "" { + t.Errorf("empty manifestPointer should remain empty, got %q", flags.manifestPointer) + } + if flags.src != "" { + t.Errorf("empty src should remain empty, got %q", flags.src) + } +} + +// TestAbsolutizeRelativeManifestPaths_SrcEscapeRegression specifically guards +// against the bug where absolutizing --src before chdir + relativization in +// InitAction.Run would produce "..\src" and write agent files outside the +// newly created project directory. +func TestAbsolutizeRelativeManifestPaths_SrcEscapeRegression(t *testing.T) { + originalCwd := t.TempDir() + t.Chdir(originalCwd) + + flags := &initFlags{ + manifestPointer: "agent.yaml", + src: "src", + } + if err := absolutizeRelativeManifestPaths(flags); err != nil { + t.Fatalf("absolutizeRelativeManifestPaths: %v", err) + } + + // Simulate ensureProject creating + chdir'ing into the project folder. + projectDir := filepath.Join(originalCwd, "my-agent") + //nolint:gosec // test fixture directory permissions are intentional + if err := os.MkdirAll(projectDir, 0o755); err != nil { + t.Fatalf("MkdirAll: %v", err) + } + t.Chdir(projectDir) + + // Mirror what InitAction.Run does: relativize absolute src against the + // project root. This must NOT escape the project. + if filepath.IsAbs(flags.src) { + rel, err := filepath.Rel(projectDir, flags.src) + if err != nil { + t.Fatalf("filepath.Rel: %v", err) + } + if strings.HasPrefix(rel, "..") { + t.Fatalf("src rewrite escapes project: %q (was abs %q)", rel, flags.src) + } + } + // Even simpler: src should never have been touched in the first place. + if flags.src != "src" { + t.Errorf("src must remain relative %q to stay inside project, got %q", "src", flags.src) + } +}