From e705c4854746110eb90495a1eb82eb01dd80c59a Mon Sep 17 00:00:00 2001 From: John Miller Date: Tue, 3 Mar 2026 12:44:42 -0500 Subject: [PATCH 1/3] Add commands for agent management and enhance functionality - Implement `invoke` command to send messages to agents, supporting both local and remote invocation. - Introduce `list` command to retrieve and display all deployed agents in a Foundry project, with options for JSON and table output. - Add `monitor` command to stream logs from agent containers, with auto-detection of agent name and version. - Enhance `show` command to retrieve runtime status of agents, with improved auto-resolution of agent details. - Update error codes to include new cases for invalid arguments and template download failures. - Create unit tests for template URL resolution, repository slug extraction, and agent YAML file discovery. --- .../azure.ai.agents/internal/cmd/delete.go | 105 ++ .../azure.ai.agents/internal/cmd/deploy.go | 84 ++ .../azure.ai.agents/internal/cmd/dev.go | 370 +++++ .../azure.ai.agents/internal/cmd/helpers.go | 306 ++++ .../azure.ai.agents/internal/cmd/init.go | 388 ++++-- .../internal/cmd/init_from_code.go | 121 +- .../internal/cmd/init_from_code_test.go | 134 ++ .../internal/cmd/init_from_template.go | 1226 +++++++++++++++++ .../internal/cmd/init_from_template_test.go | 319 +++++ .../azure.ai.agents/internal/cmd/invoke.go | 453 ++++++ .../azure.ai.agents/internal/cmd/list.go | 163 +++ .../azure.ai.agents/internal/cmd/monitor.go | 34 +- .../azure.ai.agents/internal/cmd/root.go | 5 + .../azure.ai.agents/internal/cmd/show.go | 33 +- .../internal/exterrors/codes.go | 3 + 15 files changed, 3572 insertions(+), 172 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/dev.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_template.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_template_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/list.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go new file mode 100644 index 00000000000..bb062d1cccd --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/delete.go @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "bufio" + "context" + "fmt" + "os" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type deleteFlags struct { + accountName string + projectName string + name string +} + +type DeleteAction struct { + *AgentContext + flags *deleteFlags +} + +func newDeleteCommand() *cobra.Command { + flags := &deleteFlags{} + + cmd := &cobra.Command{ + Use: "delete", + Short: "Delete a deployed agent.", + Long: `Delete a deployed agent from the Foundry project. + +Permanently removes the agent and all its versions. This action cannot be undone. +You will be prompted for confirmation unless --no-prompt is set.`, + Example: ` # Delete an agent (will prompt for confirmation) + azd ai agent delete --name my-agent + + # Delete without confirmation + azd ai agent delete --name my-agent --no-prompt`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + + agentContext, err := newAgentContext(ctx, flags.accountName, flags.projectName, flags.name, "") + if err != nil { + return err + } + + action := &DeleteAction{ + AgentContext: agentContext, + flags: flags, + } + + return action.Run(ctx) + }, + } + + cmd.Flags().StringVarP(&flags.accountName, "account-name", "a", "", "Cognitive Services account name") + cmd.Flags().StringVarP(&flags.projectName, "project-name", "p", "", "AI Foundry project name") + cmd.Flags().StringVarP(&flags.name, "name", "n", "", "Name of the agent to delete (required)") + + _ = cmd.MarkFlagRequired("name") + + return cmd +} + +func (a *DeleteAction) Run(ctx context.Context) error { + // Confirm deletion unless --no-prompt is set + if !rootFlags.NoPrompt { + fmt.Printf("Delete agent '%s'? This cannot be undone. [y/N]: ", a.Name) + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + fmt.Println("Cancelled.") + return nil + } + } + + agentClient, err := a.NewClient() + if err != nil { + return err + } + + _, err = agentClient.DeleteAgent(ctx, a.Name, DefaultAgentAPIVersion) + if err != nil { + return fmt.Errorf("failed to delete agent '%s': %w", a.Name, err) + } + + // Clean up local session/conversation state + localCtx := loadLocalContext() + if localCtx.Sessions != nil { + delete(localCtx.Sessions, a.Name) + } + if localCtx.Conversations != nil { + delete(localCtx.Conversations, a.Name) + } + _ = saveLocalContext(localCtx) + + fmt.Printf("Agent '%s' deleted.\n", a.Name) + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go new file mode 100644 index 00000000000..4280bd25e7a --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + + "github.com/spf13/cobra" +) + +type deployFlags struct { + service string +} + +func newDeployCommand() *cobra.Command { + flags := &deployFlags{} + + cmd := &cobra.Command{ + Use: "deploy", + Short: "Deploy your agent to Azure AI Foundry.", + Long: `Deploy your agent to Azure AI Foundry. + +This command runs 'azd deploy' scoped to your agent service. It +automatically detects the azure.ai.agent service from azure.yaml and +passes --service to deploy only that service (not the full project). + +The deployment lifecycle (build → push → deploy) runs through the azd +extension hooks, using the service configuration from azure.yaml and the +agent definition from agent.yaml.`, + Example: ` # Deploy the agent service (auto-detected from azure.yaml) + azd ai agent deploy + + # Deploy a specific agent service by name + azd ai agent deploy --service my-agent`, + RunE: func(cmd *cobra.Command, args []string) error { + setupDebugLogging(cmd.Flags()) + + return runDeploy(cmd.Context(), flags) + }, + } + + cmd.Flags().StringVar(&flags.service, "service", "", "Name of the agent service to deploy (from azure.yaml)") + + return cmd +} + +func runDeploy(ctx context.Context, flags *deployFlags) error { + serviceName := flags.service + + // Auto-detect the agent service name from azure.yaml if not provided + if serviceName == "" { + info, err := resolveAgentServiceFromProject(ctx, "") + if err != nil { + return fmt.Errorf( + "could not detect agent service from azure.yaml: %w\n\n"+ + "Use --service to specify the service name explicitly", err) + } + serviceName = info.ServiceName + fmt.Fprintf(os.Stderr, "Detected agent service: %s\n", serviceName) + } + + args := []string{"deploy", "--service", serviceName} + + fmt.Fprintf(os.Stderr, "Running: azd %s\n\n", strings.Join(args, " ")) + + azdCmd := exec.Command("azd", args...) + azdCmd.Stdout = os.Stdout + azdCmd.Stderr = os.Stderr + azdCmd.Stdin = os.Stdin + + if err := azdCmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return fmt.Errorf("azd deploy failed with exit code %d", exitErr.ExitCode()) + } + return fmt.Errorf("failed to run azd deploy: %w", err) + } + + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/dev.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/dev.go new file mode 100644 index 00000000000..e58668bbb38 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/dev.go @@ -0,0 +1,370 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "os" + "os/exec" + "os/signal" + "path/filepath" + "runtime" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type devFlags struct { + src string + port int + name string + startCommand string +} + +func newDevCommand() *cobra.Command { + flags := &devFlags{} + + cmd := &cobra.Command{ + Use: "dev", + Short: "Run your agent locally for development.", + Long: `Run your agent locally for development. + +Detects the project type (Python, .NET, Node.js), installs dependencies, +and starts the agent server in the foreground. Press Ctrl+C to stop. + +The startup command is read from the startupCommand property of the +agent service in azure.yaml. If not set, it is auto-detected from the +project type. Use --start-command to override both. + +Use a separate terminal to invoke the running agent: + azd ai agent invoke "Hello!"`, + Example: ` # Start the agent in the current directory + azd ai agent dev + + # Start from a specific source directory + azd ai agent dev --src ./my-agent + + # Start a specific agent by name + azd ai agent dev --name my-agent + + # Start on a custom port + azd ai agent dev --port 9090 + + # Start with an explicit command + azd ai agent dev --start-command "python app.py"`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := azdext.WithAccessToken(cmd.Context()) + setupDebugLogging(cmd.Flags()) + return runDev(ctx, flags) + }, + } + + cmd.Flags().StringVarP(&flags.src, "src", "s", ".", "Project source directory") + cmd.Flags().IntVarP(&flags.port, "port", "p", DefaultPort, "Port to listen on") + cmd.Flags().StringVarP(&flags.name, "name", "n", "", "Agent service name (from azure.yaml)") + cmd.Flags().StringVarP(&flags.startCommand, "start-command", "c", "", + "Explicit startup command (overrides azure.yaml and auto-detection)") + + return cmd +} + +func runDev(ctx context.Context, flags *devFlags) error { + projectDir, err := filepath.Abs(flags.src) + if err != nil { + return fmt.Errorf("invalid source directory: %w", err) + } + + if _, err := os.Stat(projectDir); os.IsNotExist(err) { + return fmt.Errorf("source directory does not exist: %s", projectDir) + } + + // Resolve start command: --start-command flag > azure.yaml startupCommand > detect + startCmd := flags.startCommand + if startCmd == "" { + startCmd = resolveStartupCommandFromService(ctx, flags.name) + } + + if startCmd == "" { + pt := detectProjectType(projectDir) + if pt.StartCmd != "" { + startCmd = pt.StartCmd + fmt.Printf("Detected %s project. Start command: %s\n", pt.Language, startCmd) + } else { + return fmt.Errorf( + "could not detect project type in %s\n\n"+ + "Supported project types:\n"+ + " - Python (pyproject.toml or requirements.txt)\n"+ + " - .NET (*.csproj)\n"+ + " - Node.js (package.json)\n\n"+ + "Use --start-command to specify explicitly, or set startupCommand in azure.yaml", + projectDir, + ) + } + } else { + fmt.Printf("Using startup command from azure.yaml: %s\n", startCmd) + } + + // Install dependencies + if err := installDependencies(projectDir); err != nil { + return fmt.Errorf("failed to install dependencies: %w", err) + } + + // Build the command + cmdParts := parseCommand(startCmd) + if len(cmdParts) == 0 { + return fmt.Errorf("empty start command") + } + + cmdParts = resolveVenvCommand(projectDir, cmdParts) + + env := os.Environ() + env = append(env, fmt.Sprintf("PORT=%d", flags.port)) + + // Load azd environment variables (e.g., AZURE_AI_PROJECT_ENDPOINT) + // so the agent can reach Azure services during local development + if azdEnvVars, err := loadAzdEnvironment(ctx); err == nil { + for k, v := range azdEnvVars { + env = append(env, fmt.Sprintf("%s=%s", k, v)) + } + } + + url := fmt.Sprintf("http://localhost:%d", flags.port) + fmt.Println() + fmt.Println("In another terminal, try:") + fmt.Printf(" azd ai agent invoke \"Hello!\"\n\n") + fmt.Printf("Starting agent on %s (Ctrl+C to stop)\n\n", url) + + // Create command with stdout/stderr piped to terminal + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + proc := exec.CommandContext(ctx, cmdParts[0], cmdParts[1:]...) + proc.Dir = projectDir + proc.Env = env + proc.Stdout = os.Stdout + proc.Stderr = os.Stderr + proc.Stdin = os.Stdin + + if err := proc.Start(); err != nil { + return fmt.Errorf("failed to start agent: %w", err) + } + + // Handle Ctrl+C: forward signal to child, then wait for it to exit + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt) + go func() { + <-sigCh + fmt.Println("\nStopping agent...") + cancel() + }() + + err = proc.Wait() + + // Suppress the noisy "signal: interrupt" error on Ctrl+C + if ctx.Err() != nil { + fmt.Println("Agent stopped.") + return nil + } + + if err != nil { + return fmt.Errorf("agent exited: %w", err) + } + return nil +} + +// --- Dependency installation --- + +func installDependencies(projectDir string) error { + pt := detectProjectType(projectDir) + + switch pt.Language { + case "python": + return installPythonDeps(projectDir) + case "node": + return installNodeDeps(projectDir) + case "dotnet": + return nil + } + return nil +} + +func installPythonDeps(projectDir string) error { + if _, err := exec.LookPath("uv"); err != nil { + fmt.Println("Warning: uv is not installed. Install it from https://docs.astral.sh/uv/") + fmt.Println("Falling back to pip...") + return installPythonDepsPip(projectDir) + } + + venvDir := filepath.Join(projectDir, ".venv") + if _, err := os.Stat(venvDir); os.IsNotExist(err) { + fmt.Println("Setting up Python environment...") + cmd := exec.Command("uv", "venv", venvDir, "--python", ">=3.12") + cmd.Dir = projectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to create venv: %w", err) + } + } + + pythonPath := venvPython(venvDir) + + if fileExists(filepath.Join(projectDir, "pyproject.toml")) { + fmt.Println("Installing dependencies (pyproject.toml)...") + cmd := exec.Command("uv", "pip", "install", "-e", ".", "--python", pythonPath, "--prerelease", "allow", "--quiet") + cmd.Dir = projectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("uv pip install failed: %w", err) + } + fmt.Println(" ✓ Dependencies installed (pyproject.toml)") + } + + if fileExists(filepath.Join(projectDir, "requirements.txt")) { + fmt.Println("Installing dependencies (requirements.txt)...") + cmd := exec.Command("uv", "pip", "install", "-r", "requirements.txt", "--python", pythonPath, "--prerelease", "allow", "--quiet") + cmd.Dir = projectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("uv pip install failed: %w", err) + } + fmt.Println(" ✓ Dependencies installed (requirements.txt)") + } + + return nil +} + +func installPythonDepsPip(projectDir string) error { + if fileExists(filepath.Join(projectDir, "requirements.txt")) { + fmt.Println("Installing dependencies (requirements.txt)...") + cmd := exec.Command("pip", "install", "-r", "requirements.txt", "-q") + cmd.Dir = projectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("pip install failed: %w", err) + } + fmt.Println(" ✓ Dependencies installed (requirements.txt)") + } + return nil +} + +func installNodeDeps(projectDir string) error { + if fileExists(filepath.Join(projectDir, "package.json")) { + fmt.Println("Installing dependencies (package.json)...") + cmd := exec.Command("npm", "install", "--quiet") + cmd.Dir = projectDir + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + if err := cmd.Run(); err != nil { + return fmt.Errorf("npm install failed: %w", err) + } + fmt.Println(" ✓ Dependencies installed (package.json)") + } + return nil +} + +// --- Command parsing utilities --- + +func parseCommand(cmd string) []string { + var parts []string + var current strings.Builder + inQuote := false + quoteChar := byte(0) + + for i := 0; i < len(cmd); i++ { + c := cmd[i] + if inQuote { + if c == quoteChar { + inQuote = false + } else { + current.WriteByte(c) + } + } else if c == '"' || c == '\'' { + inQuote = true + quoteChar = c + } else if c == ' ' { + if current.Len() > 0 { + parts = append(parts, current.String()) + current.Reset() + } + } else { + current.WriteByte(c) + } + } + if current.Len() > 0 { + parts = append(parts, current.String()) + } + return parts +} + +func resolveVenvCommand(projectDir string, cmdParts []string) []string { + if len(cmdParts) == 0 { + return cmdParts + } + + venvDir := filepath.Join(projectDir, ".venv") + if _, err := os.Stat(venvDir); os.IsNotExist(err) { + return cmdParts + } + + pythonPath := venvPython(venvDir) + + if cmdParts[0] == "python" || cmdParts[0] == "python3" { + cmdParts[0] = pythonPath + } else { + binDir := venvBinDir(venvDir) + binPath := filepath.Join(binDir, cmdParts[0]) + if fileExists(binPath) { + cmdParts[0] = binPath + } + } + + return cmdParts +} + +func venvPython(venvDir string) string { + if runtime.GOOS == "windows" { + return filepath.Join(venvDir, "Scripts", "python.exe") + } + return filepath.Join(venvDir, "bin", "python") +} + +func venvBinDir(venvDir string) string { + if runtime.GOOS == "windows" { + return filepath.Join(venvDir, "Scripts") + } + return filepath.Join(venvDir, "bin") +} + +// loadAzdEnvironment reads all key-value pairs from the current azd environment. +func loadAzdEnvironment(ctx context.Context) (map[string]string, error) { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return nil, err + } + defer azdClient.Close() + + envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return nil, err + } + + resp, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: envResponse.Environment.Name, + }) + if err != nil { + return nil, err + } + + result := make(map[string]string, len(resp.KeyValues)) + for _, kv := range resp.KeyValues { + result[kv.Key] = kv.Value + } + return result, nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go new file mode 100644 index 00000000000..380d60d3805 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/helpers.go @@ -0,0 +1,306 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "crypto/rand" + "encoding/json" + "fmt" + "math/big" + "os" + "path/filepath" + "strings" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +const ( + // ConfigFile is the project-level state file for local agent context. + ConfigFile = ".foundry-agent.json" + + // DefaultPort is the default port for local agent servers. + DefaultPort = 8088 +) + +// AgentLocalContext holds local state persisted in .foundry-agent.json. +type AgentLocalContext struct { + AgentName string `json:"agent_name,omitempty"` + Sessions map[string]string `json:"sessions,omitempty"` + Conversations map[string]string `json:"conversations,omitempty"` +} + +// loadLocalContext reads the .foundry-agent.json state file from the project root. +func loadLocalContext() *AgentLocalContext { + data, err := os.ReadFile(ConfigFile) + if err != nil { + return &AgentLocalContext{} + } + var ctx AgentLocalContext + if err := json.Unmarshal(data, &ctx); err != nil { + return &AgentLocalContext{} + } + return &ctx +} + +// saveLocalContext writes the .foundry-agent.json state file. +func saveLocalContext(ctx *AgentLocalContext) error { + data, err := json.MarshalIndent(ctx, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal local context: %w", err) + } + return os.WriteFile(ConfigFile, append(data, '\n'), 0644) +} + +// resolveAgentNameLocal resolves the agent name from: explicit flag > .foundry-agent.json > error. +func resolveAgentNameLocal(name string) (string, error) { + if name != "" { + return name, nil + } + ctx := loadLocalContext() + if ctx.AgentName != "" { + return ctx.AgentName, nil + } + return "", fmt.Errorf("no agent name specified; use --name or run 'azd ai agent init' first") +} + +// resolveSessionID resolves or generates a session ID for invoke. +// Returns the session ID (existing or newly generated). +func resolveSessionID(agentName string, explicit string, forceNew bool) string { + if explicit != "" { + return explicit + } + ctx := loadLocalContext() + if ctx.Sessions == nil { + ctx.Sessions = make(map[string]string) + } + if !forceNew { + if sid, ok := ctx.Sessions[agentName]; ok { + return sid + } + } + sid := generateSessionID() + ctx.Sessions[agentName] = sid + _ = saveLocalContext(ctx) + return sid +} + +// resolveConversationID resolves or creates a Foundry conversation ID. +// Returns empty string if creation fails (multi-turn memory disabled). +func resolveConversationID(agentName string, forceNew bool) string { + ctx := loadLocalContext() + if ctx.Conversations == nil { + ctx.Conversations = make(map[string]string) + } + if !forceNew { + if convID, ok := ctx.Conversations[agentName]; ok { + return convID + } + } + // Conversation creation requires an API call — handled by the invoke command. + return "" +} + +// saveConversationID persists a conversation ID for an agent. +func saveConversationID(agentName, convID string) { + ctx := loadLocalContext() + if ctx.Conversations == nil { + ctx.Conversations = make(map[string]string) + } + ctx.Conversations[agentName] = convID + _ = saveLocalContext(ctx) +} + +// generateSessionID creates a random 25-character session ID (lowercase + digits). +func generateSessionID() string { + const chars = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, 25) + for i := range b { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(chars)))) + if err != nil { + panic(fmt.Sprintf("crypto/rand failed: %v", err)) + } + b[i] = chars[n.Int64()] + } + return string(b) +} + +// detectProjectType detects the project type and suggests a start command. +type ProjectType struct { + Language string // "python", "dotnet", "node", "unknown" + StartCmd string // suggested start command +} + +func detectProjectType(projectDir string) ProjectType { + // Python: pyproject.toml or requirements.txt + if fileExists(filepath.Join(projectDir, "pyproject.toml")) { + return ProjectType{Language: "python", StartCmd: "python main.py"} + } + if fileExists(filepath.Join(projectDir, "requirements.txt")) { + return ProjectType{Language: "python", StartCmd: "python main.py"} + } + + // .NET: any .csproj file + matches, _ := filepath.Glob(filepath.Join(projectDir, "*.csproj")) + if len(matches) > 0 { + return ProjectType{Language: "dotnet", StartCmd: "dotnet run"} + } + + // Node.js: package.json + if fileExists(filepath.Join(projectDir, "package.json")) { + return ProjectType{Language: "node", StartCmd: "npm start"} + } + + // Check for main.py as fallback + if fileExists(filepath.Join(projectDir, "main.py")) { + return ProjectType{Language: "python", StartCmd: "python main.py"} + } + + return ProjectType{Language: "unknown", StartCmd: ""} +} + +// parseEndpoint extracts account and project names from a Foundry project endpoint URL. +// e.g., "https://myaccount.services.ai.azure.com/api/projects/myproject" → ("myaccount", "myproject") +func parseEndpoint(endpoint string) (account, project string, err error) { + endpoint = strings.TrimRight(endpoint, "/") + // Extract account from hostname + if !strings.Contains(endpoint, "://") { + return "", "", fmt.Errorf("invalid endpoint URL: %s", endpoint) + } + hostPath := strings.SplitN(endpoint, "://", 2)[1] + hostParts := strings.SplitN(hostPath, "/", 2) + hostname := hostParts[0] + account = strings.SplitN(hostname, ".", 2)[0] + + // Extract project from path + pathParts := strings.Split(endpoint, "/") + if len(pathParts) > 0 { + project = pathParts[len(pathParts)-1] + } + if account == "" || project == "" { + return "", "", fmt.Errorf("could not parse account/project from endpoint: %s", endpoint) + } + return account, project, nil +} + +func fileExists(path string) bool { + _, err := os.Stat(path) + return err == nil +} + +// AgentServiceInfo holds the resolved name and version for an agent service. +type AgentServiceInfo struct { + ServiceName string // azure.yaml service key + AgentName string // deployed agent name from env + Version string // deployed agent version from env +} + +// resolveAgentServiceFromProject finds the first azure.ai.agent service in azure.yaml +// and resolves its deployed agent name and version from the azd environment. +// The name parameter filters to a specific service; empty means use the first one found. +func resolveAgentServiceFromProject(ctx context.Context, name string) (*AgentServiceInfo, error) { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return nil, fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + projectResponse, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil || projectResponse.Project == nil { + return nil, fmt.Errorf("failed to get project config: %w", err) + } + + // Find the matching azure.ai.agent service + var svc *azdext.ServiceConfig + for _, s := range projectResponse.Project.Services { + if s.Host != AiAgentHost { + continue + } + if name != "" && s.Name != name { + continue + } + svc = s + break + } + + if svc == nil { + if name != "" { + return nil, fmt.Errorf("no azure.ai.agent service named '%s' found in azure.yaml", name) + } + return nil, fmt.Errorf("no azure.ai.agent service found in azure.yaml") + } + + info := &AgentServiceInfo{ServiceName: svc.Name} + + // Resolve agent name and version from azd environment + envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + if err != nil { + return info, nil + } + + serviceKey := toServiceKey(svc.Name) + nameKey := fmt.Sprintf("AGENT_%s_NAME", serviceKey) + versionKey := fmt.Sprintf("AGENT_%s_VERSION", serviceKey) + + if v, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envResponse.Environment.Name, + Key: nameKey, + }); err == nil && v.Value != "" { + info.AgentName = v.Value + } + + if v, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envResponse.Environment.Name, + Key: versionKey, + }); err == nil && v.Value != "" { + info.Version = v.Value + } + + return info, nil +} + +// resolveStartupCommandFromService reads startupCommand from an azure.ai.agent +// service's AdditionalProperties. Returns empty string if unavailable. +func resolveStartupCommandFromService(ctx context.Context, name string) string { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return "" + } + defer azdClient.Close() + + projectResponse, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) + if err != nil || projectResponse.Project == nil { + return "" + } + + for _, s := range projectResponse.Project.Services { + if s.Host != AiAgentHost { + continue + } + if name != "" && s.Name != name { + continue + } + if s.AdditionalProperties == nil { + return "" + } + fields := s.AdditionalProperties.GetFields() + if fields == nil { + return "" + } + v, ok := fields["startupCommand"] + if !ok || v == nil { + return "" + } + return v.GetStringValue() + } + + return "" +} + +// toServiceKey converts a service name into the env var key format (uppercase, underscores). +func toServiceKey(serviceName string) string { + key := strings.ReplaceAll(serviceName, " ", "_") + key = strings.ReplaceAll(key, "-", "_") + return strings.ToUpper(key) +} 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 f81019fa402..47b92c7bd21 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -15,7 +15,6 @@ import ( "os" "path/filepath" "regexp" - "strconv" "strings" "time" @@ -47,9 +46,11 @@ type initFlags struct { *rootFlagsDefinition projectResourceId string manifestPointer string + templateUrl string src string host string env string + infra bool } // AiProjectResourceConfig represents the configuration for an AI project resource @@ -109,7 +110,7 @@ func newInitCommand(rootFlags *rootFlagsDefinition) *cobra.Command { } cmd := &cobra.Command{ - Use: "init [-m ] [--src ]", + Use: "init [-m ] [-t