diff --git a/cli/azd/extensions/azure.ai.agents/go.mod b/cli/azd/extensions/azure.ai.agents/go.mod index 71f94343c91..bfad84820b0 100644 --- a/cli/azd/extensions/azure.ai.agents/go.mod +++ b/cli/azd/extensions/azure.ai.agents/go.mod @@ -12,7 +12,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v1.3.0-beta.3 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 - github.com/azure/azure-dev/cli/azd v1.23.14 + github.com/azure/azure-dev/cli/azd v1.24.3 github.com/braydonk/yaml v0.9.0 github.com/drone/envsubst v1.0.3 github.com/fatih/color v1.18.0 diff --git a/cli/azd/extensions/azure.ai.agents/go.sum b/cli/azd/extensions/azure.ai.agents/go.sum index 098ea504f0a..2d1a8679e86 100644 --- a/cli/azd/extensions/azure.ai.agents/go.sum +++ b/cli/azd/extensions/azure.ai.agents/go.sum @@ -19,8 +19,6 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthoriza github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/authorization/armauthorization/v3 v3.0.0-beta.2/go.mod h1:jVRrRDLCOuif95HDYC23ADTMlvahB7tMdl519m9Iyjc= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2 v2.0.0 h1:pxphC/uRZKNHNPbZ0duDDgKkefju2F03OkG5xF6byHQ= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/cognitiveservices/armcognitiveservices/v2 v2.0.0/go.mod h1:twcwRey+l1znKBL5TEzYiZMtiVkWfM7Pq8a9vY04xYc= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v1.2.0 h1:DWlwvVV5r/Wy1561nZ3wrpI1/vDIBRY/Wd1HWaRBZWA= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v1.2.0/go.mod h1:E7ltexgRDmeJ0fJWv0D/HLwY2xbDdN+uv+X2uZtOx3w= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v1.3.0-beta.3 h1:4qfc7os3wRQcl+ImfeH9z0abWJzuV9IGcN1B9olmPTU= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/containerregistry/armcontainerregistry v1.3.0-beta.3/go.mod h1:NlNAngH4e++mzPTN0+1EEvyUmwFmR91u/MQUVV230Z4= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v2 v2.0.0 h1:PTFGRSlMKCQelWwxUyYVEUqseBJVemLyqWJjvMyt0do= @@ -61,8 +59,8 @@ github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWp github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= -github.com/azure/azure-dev/cli/azd v1.23.14 h1:yfmEPu3T+FqFuHhHHX9gEc+yflAu0k1aDOkN00HlFzo= -github.com/azure/azure-dev/cli/azd v1.23.14/go.mod h1:mS/n9XZcwRrTaDcFxWFfgcsmB6AuuTNarB2IDCS5QzI= +github.com/azure/azure-dev/cli/azd v1.24.3 h1:r2kEr2YYLu4ImKo6nR/WjhHg/1SliN1uwmAVqnM8t3o= +github.com/azure/azure-dev/cli/azd v1.24.3/go.mod h1:YANepMw36aWA8/mQyXau6JCAG84oK0ZgfvLF8rN5asU= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o= diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/extension_context.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/extension_context.go new file mode 100644 index 00000000000..93dd4d56487 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/extension_context.go @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import "github.com/azure/azure-dev/cli/azd/pkg/azdext" + +// ensureExtensionContext returns a non-nil [azdext.ExtensionContext] so command +// constructors can be safely invoked from tests with a nil receiver. The SDK's +// [azdext.NewExtensionRootCommand] populates the real context (and its env-var +// fallback) before any leaf RunE runs, so tests that don't exercise RunE can +// safely pass nil here. +func ensureExtensionContext(extCtx *azdext.ExtensionContext) *azdext.ExtensionContext { + if extCtx == nil { + return &azdext.ExtensionContext{} + } + return extCtx +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go index 5706630ec32..1830db2ee08 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/files.go @@ -24,7 +24,9 @@ type filesFlags struct { session string // optional: explicit session ID override } -func newFilesCommand() *cobra.Command { +func newFilesCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + cmd := &cobra.Command{ Use: "files", Short: "Manage files in a hosted agent session.", @@ -37,26 +39,14 @@ Agent details (name, endpoint) are automatically resolved from the azd environment. Use --agent-name to select a specific agent when the project has multiple azure.ai.agent services. The session ID is automatically resolved from the last invoke session, or can be overridden with --session-id.`, - PersistentPreRunE: func(cmd *cobra.Command, args []string) error { - // Chain with root's PersistentPreRunE (root sets NoPrompt). - // Note: cmd.Parent() would return the "files" command itself when - // a subcommand runs, causing infinite recursion. - if root := cmd.Root(); root != nil && root.PersistentPreRunE != nil { - if err := root.PersistentPreRunE(cmd, args); err != nil { - return err - } - } - - return nil - }, } - cmd.AddCommand(newFilesUploadCommand()) - cmd.AddCommand(newFilesDownloadCommand()) - cmd.AddCommand(newFilesListCommand()) - cmd.AddCommand(newFilesRemoveCommand()) - cmd.AddCommand(newFilesMkdirCommand()) - cmd.AddCommand(newFilesStatCommand()) + cmd.AddCommand(newFilesUploadCommand(extCtx)) + cmd.AddCommand(newFilesDownloadCommand(extCtx)) + cmd.AddCommand(newFilesListCommand(extCtx)) + cmd.AddCommand(newFilesRemoveCommand(extCtx)) + cmd.AddCommand(newFilesMkdirCommand(extCtx)) + cmd.AddCommand(newFilesStatCommand(extCtx)) return cmd } @@ -74,14 +64,14 @@ type filesContext struct { } // resolveFilesContext resolves agent details and session from the azd environment. -func resolveFilesContext(ctx context.Context, flags *filesFlags) (*filesContext, error) { +func resolveFilesContext(ctx context.Context, flags *filesFlags, noPrompt bool) (*filesContext, error) { azdClient, err := azdext.NewAzdClient() if err != nil { return nil, fmt.Errorf("failed to create azd client: %w", err) } defer azdClient.Close() - info, err := resolveAgentServiceFromProject(ctx, azdClient, flags.agentName, rootFlags.NoPrompt) + info, err := resolveAgentServiceFromProject(ctx, azdClient, flags.agentName, noPrompt) if err != nil { return nil, err } @@ -137,8 +127,9 @@ type FilesUploadAction struct { sessionID string } -func newFilesUploadCommand() *cobra.Command { +func newFilesUploadCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &filesUploadFlags{} + extCtx = ensureExtensionContext(extCtx) cmd := &cobra.Command{ Use: "upload [file]", @@ -161,8 +152,6 @@ Agent details are automatically resolved from the azd environment.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) - logCleanup := setupDebugLogging(cmd.Flags()) - defer logCleanup() if len(args) > 0 && flags.file == "" { flags.file = args[0] @@ -174,7 +163,7 @@ Agent details are automatically resolved from the azd environment.`, ) } - fc, err := resolveFilesContext(ctx, &flags.filesFlags) + fc, err := resolveFilesContext(ctx, &flags.filesFlags, extCtx.NoPrompt) if err != nil { return err } @@ -246,8 +235,9 @@ type FilesDownloadAction struct { sessionID string } -func newFilesDownloadCommand() *cobra.Command { +func newFilesDownloadCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &filesDownloadFlags{} + extCtx = ensureExtensionContext(extCtx) cmd := &cobra.Command{ Use: "download [file]", @@ -270,8 +260,6 @@ Agent details are automatically resolved from the azd environment.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) - logCleanup := setupDebugLogging(cmd.Flags()) - defer logCleanup() if len(args) > 0 && flags.file == "" { flags.file = args[0] @@ -283,7 +271,7 @@ Agent details are automatically resolved from the azd environment.`, ) } - fc, err := resolveFilesContext(ctx, &flags.filesFlags) + fc, err := resolveFilesContext(ctx, &flags.filesFlags, extCtx.NoPrompt) if err != nil { return err } @@ -359,8 +347,9 @@ type FilesListAction struct { remotePath string } -func newFilesListCommand() *cobra.Command { +func newFilesListCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &filesListFlags{} + extCtx = ensureExtensionContext(extCtx) cmd := &cobra.Command{ Use: "list [remote-path]", @@ -385,11 +374,11 @@ Agent details are automatically resolved from the azd environment.`, azd ai agent files list --session-id `, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + flags.output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) - logCleanup := setupDebugLogging(cmd.Flags()) - defer logCleanup() - fc, err := resolveFilesContext(ctx, &flags.filesFlags) + fc, err := resolveFilesContext(ctx, &flags.filesFlags, extCtx.NoPrompt) if err != nil { return err } @@ -411,7 +400,11 @@ Agent details are automatically resolved from the azd environment.`, } addFilesFlags(cmd, &flags.filesFlags) - cmd.Flags().StringVar(&flags.output, "output", "json", "Output format (json or table)") + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "table"}, + Default: "json", + }) return cmd } @@ -486,9 +479,10 @@ type FilesRemoveAction struct { remotePath string } -func newFilesRemoveCommand() *cobra.Command { +func newFilesRemoveCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &filesRemoveFlags{} var filePath string + extCtx = ensureExtensionContext(extCtx) cmd := &cobra.Command{ Use: "delete [file]", @@ -511,8 +505,6 @@ Agent details are automatically resolved from the azd environment.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) - logCleanup := setupDebugLogging(cmd.Flags()) - defer logCleanup() if len(args) > 0 && filePath == "" { filePath = args[0] @@ -524,7 +516,7 @@ Agent details are automatically resolved from the azd environment.`, ) } - fc, err := resolveFilesContext(ctx, &flags.filesFlags) + fc, err := resolveFilesContext(ctx, &flags.filesFlags, extCtx.NoPrompt) if err != nil { return err } @@ -579,9 +571,10 @@ type FilesMkdirAction struct { remotePath string } -func newFilesMkdirCommand() *cobra.Command { +func newFilesMkdirCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &filesFlags{} var dirPath string + extCtx = ensureExtensionContext(extCtx) cmd := &cobra.Command{ Use: "mkdir [dir]", @@ -600,8 +593,6 @@ Agent details are automatically resolved from the azd environment.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) - logCleanup := setupDebugLogging(cmd.Flags()) - defer logCleanup() if len(args) > 0 && dirPath == "" { dirPath = args[0] @@ -613,7 +604,7 @@ Agent details are automatically resolved from the azd environment.`, ) } - fc, err := resolveFilesContext(ctx, flags) + fc, err := resolveFilesContext(ctx, flags, extCtx.NoPrompt) if err != nil { return err } @@ -671,8 +662,9 @@ type FilesStatAction struct { remotePath string } -func newFilesStatCommand() *cobra.Command { +func newFilesStatCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &filesStatFlags{} + extCtx = ensureExtensionContext(extCtx) cmd := &cobra.Command{ Use: "stat ", @@ -692,11 +684,11 @@ Agent details are automatically resolved from the azd environment.`, azd ai agent files stat /data/output.csv --session-id `, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + flags.output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) - logCleanup := setupDebugLogging(cmd.Flags()) - defer logCleanup() - fc, err := resolveFilesContext(ctx, &flags.filesFlags) + fc, err := resolveFilesContext(ctx, &flags.filesFlags, extCtx.NoPrompt) if err != nil { return err } @@ -713,7 +705,11 @@ Agent details are automatically resolved from the azd environment.`, } addFilesFlags(cmd, &flags.filesFlags) - cmd.Flags().StringVarP(&flags.output, "output", "o", "json", "Output format (json or table)") + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "table"}, + Default: "json", + }) return cmd } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go index ea28f362211..124e87d205e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/files_test.go @@ -8,31 +8,12 @@ import ( "azureaiagent/internal/pkg/agents/agent_api" - "github.com/spf13/cobra" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -func TestFilesCommand_PersistentPreRunE_NoRecursion(t *testing.T) { - // Regression: PersistentPreRunE previously used cmd.Parent() which returned - // the "files" command itself when a subcommand ran, causing infinite recursion - // (stack overflow). The fix uses cmd.Root() to chain to the root command. - root := NewRootCommand() - - // Override the list subcommand's RunE so it doesn't need a real environment. - filesCmd, _, _ := root.Find([]string{"files"}) - require.NotNil(t, filesCmd) - for _, sub := range filesCmd.Commands() { - sub.RunE = func(cmd *cobra.Command, args []string) error { return nil } - } - - root.SetArgs([]string{"files", "list"}) - // If the bug is present this will stack-overflow instead of returning. - _ = root.Execute() -} - func TestFilesCommand_HasSubcommands(t *testing.T) { - cmd := newFilesCommand() + cmd := newFilesCommand(nil) subcommands := cmd.Commands() names := make([]string, len(subcommands)) @@ -47,7 +28,7 @@ func TestFilesCommand_HasSubcommands(t *testing.T) { } func TestFilesUploadCommand_MissingFile(t *testing.T) { - cmd := newFilesUploadCommand() + cmd := newFilesUploadCommand(nil) // Missing required --file flag cmd.SetArgs([]string{}) @@ -57,7 +38,7 @@ func TestFilesUploadCommand_MissingFile(t *testing.T) { } func TestFilesUploadCommand_HasFlags(t *testing.T) { - cmd := newFilesUploadCommand() + cmd := newFilesUploadCommand(nil) for _, name := range []string{"file", "target-path", "agent-name", "session-id"} { f := cmd.Flags().Lookup(name) @@ -67,7 +48,7 @@ func TestFilesUploadCommand_HasFlags(t *testing.T) { } func TestFilesDownloadCommand_MissingFile(t *testing.T) { - cmd := newFilesDownloadCommand() + cmd := newFilesDownloadCommand(nil) // Missing required --file flag cmd.SetArgs([]string{}) @@ -77,7 +58,7 @@ func TestFilesDownloadCommand_MissingFile(t *testing.T) { } func TestFilesDownloadCommand_HasFlags(t *testing.T) { - cmd := newFilesDownloadCommand() + cmd := newFilesDownloadCommand(nil) for _, name := range []string{"file", "target-path", "agent-name", "session-id"} { f := cmd.Flags().Lookup(name) @@ -87,21 +68,19 @@ func TestFilesDownloadCommand_HasFlags(t *testing.T) { } func TestFilesListCommand_DefaultOutputFormat(t *testing.T) { - cmd := newFilesListCommand() - - output, _ := cmd.Flags().GetString("output") - assert.Equal(t, "json", output) + cmd := newFilesListCommand(nil) + assertOutputFlagOptions(t, cmd, "json", []string{"json", "table"}) } func TestFilesListCommand_OptionalRemotePath(t *testing.T) { - cmd := newFilesListCommand() + cmd := newFilesListCommand(nil) // Verify the command accepts 0 or 1 args assert.NotNil(t, cmd.Args) } func TestFilesDeleteCommand_MissingFile(t *testing.T) { - cmd := newFilesRemoveCommand() + cmd := newFilesRemoveCommand(nil) // Missing required --file flag cmd.SetArgs([]string{}) @@ -111,7 +90,7 @@ func TestFilesDeleteCommand_MissingFile(t *testing.T) { } func TestFilesDeleteCommand_HasFlags(t *testing.T) { - cmd := newFilesRemoveCommand() + cmd := newFilesRemoveCommand(nil) for _, name := range []string{"file", "recursive", "agent-name", "session-id"} { f := cmd.Flags().Lookup(name) @@ -123,7 +102,7 @@ func TestFilesDeleteCommand_HasFlags(t *testing.T) { } func TestFilesMkdirCommand_MissingDir(t *testing.T) { - cmd := newFilesMkdirCommand() + cmd := newFilesMkdirCommand(nil) // Missing required --dir flag cmd.SetArgs([]string{}) @@ -133,7 +112,7 @@ func TestFilesMkdirCommand_MissingDir(t *testing.T) { } func TestFilesMkdirCommand_HasFlags(t *testing.T) { - cmd := newFilesMkdirCommand() + cmd := newFilesMkdirCommand(nil) for _, name := range []string{"dir", "agent-name", "session-id"} { f := cmd.Flags().Lookup(name) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/flag_options_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/flag_options_test.go new file mode 100644 index 00000000000..7d1abb3d95d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/flag_options_test.go @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// assertOutputFlagOptions verifies that cmd has the per-command --output flag +// configuration registered via [azdext.RegisterFlagOptions]. The SDK records +// these as cobra annotations rather than as a redeclared flag value, so we +// inspect cmd.Annotations directly rather than reading from cmd.Flags(). +func assertOutputFlagOptions(t *testing.T, cmd *cobra.Command, wantDefault string, wantAllowed []string) { + t.Helper() + require.NotNil(t, cmd) + require.NotNil(t, cmd.Annotations, "cmd.Annotations should be set by RegisterFlagOptions") + + got := cmd.Annotations["azdext.default/output"] + assert.Equal(t, wantDefault, got, "default for --output") + + allowedJSON := cmd.Annotations["azdext.allowed-values/output"] + require.NotEmpty(t, allowedJSON, "allowed values for --output should be set") + var allowed []string + require.NoError(t, json.Unmarshal([]byte(allowedJSON), &allowed)) + assert.Equal(t, wantAllowed, allowed, "allowed values for --output") +} 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 a2216edc13f..d5838ff6769 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -43,7 +43,6 @@ import ( ) type initFlags struct { - *rootFlagsDefinition projectResourceId string modelDeployment string model string @@ -51,6 +50,9 @@ type initFlags struct { src string env string protocols []string + // noPrompt is resolved from the extension context (--no-prompt / AZD_NO_PROMPT) + // and is not registered as a CLI flag on the init command itself. + noPrompt bool } // AiProjectResourceConfig represents the configuration for an AI project resource @@ -276,16 +278,20 @@ func runInitFromManifest( return action.Run(ctx) } -func newInitCommand(rootFlags *rootFlagsDefinition) *cobra.Command { - flags := &initFlags{ - rootFlagsDefinition: rootFlags, - } +func newInitCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + flags := &initFlags{} + extCtx = ensureExtensionContext(extCtx) cmd := &cobra.Command{ Use: "init [] [-m ] [--src ]", Short: fmt.Sprintf("Initialize a new AI agent project. %s", color.YellowString("(Preview)")), Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + flags.noPrompt = extCtx.NoPrompt + if flags.env == "" { + flags.env = extCtx.Environment + } + printBanner(cmd.OutOrStdout()) // Resolve optional positional argument into --manifest or --src @@ -297,9 +303,6 @@ func newInitCommand(rootFlags *rootFlagsDefinition) *cobra.Command { ctx := azdext.WithAccessToken(cmd.Context()) - logCleanup := setupDebugLogging(cmd.Flags()) - defer logCleanup() - azdClient, err := azdext.NewAzdClient() if err != nil { return exterrors.Internal(exterrors.CodeAzdClientFailed, fmt.Sprintf("failed to create azd client: %s", err)) @@ -338,8 +341,8 @@ func newInitCommand(rootFlags *rootFlagsDefinition) *cobra.Command { return fmt.Errorf("checking for existing manifest: %w", detectErr) } if detected != "" { - useExisting := flags.NoPrompt - if !flags.NoPrompt { + useExisting := flags.noPrompt + if !flags.noPrompt { confirmResp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ Options: &azdext.ConfirmOptions{ Message: fmt.Sprintf( @@ -392,7 +395,7 @@ func newInitCommand(rootFlags *rootFlagsDefinition) *cobra.Command { switch initMode { case initModeTemplate: // User chose to start from a template - select one - selectedTemplate, err := promptAgentTemplate(ctx, azdClient, httpClient, flags.NoPrompt) + selectedTemplate, err := promptAgentTemplate(ctx, azdClient, httpClient, flags.noPrompt) if err != nil { if exterrors.IsCancellation(err) { return exterrors.Cancelled("initialization was cancelled") @@ -514,8 +517,6 @@ func newInitCommand(rootFlags *rootFlagsDefinition) *cobra.Command { cmd.Flags().StringVarP(&flags.src, "src", "s", "", "Directory to download the agent definition to (defaults to 'src/')") - cmd.Flags().StringVar(&flags.env, "environment", "", "The name of the azd environment to use.") - cmd.Flags().StringSliceVar(&flags.protocols, "protocol", nil, "Protocols supported by the agent (e.g., 'responses', 'invocations'). Can be specified multiple times.") @@ -594,7 +595,7 @@ func (a *InitAction) Run(ctx context.Context) error { // Prompt for manifest parameters (e.g. tool credentials) after project selection agentManifest, err = agent_yaml.ProcessManifestParameters( - ctx, agentManifest, a.azdClient, a.flags.NoPrompt, + ctx, agentManifest, a.azdClient, a.flags.noPrompt, ) if err != nil { return fmt.Errorf("failed to process manifest parameters: %w", err) @@ -1557,7 +1558,7 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa } // Detect startup command from the project source directory - startupCmd, err := resolveStartupCommandForInit(ctx, a.azdClient, a.projectConfig.Path, targetDir, a.flags.NoPrompt) + startupCmd, err := resolveStartupCommandForInit(ctx, a.azdClient, a.projectConfig.Path, targetDir, a.flags.noPrompt) if err != nil { return err } @@ -1642,7 +1643,7 @@ func (a *InitAction) resolveCollisions( return "", "", err } - if a.flags.NoPrompt { + if a.flags.noPrompt { log.Printf( "Collision on %q; using %q", agentId, suggestion, ) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy.go index b76d1080842..e9e153a7b85 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_copy.go @@ -60,7 +60,7 @@ func (a *InitAction) validateLocalContainerAgentCopy(ctx context.Context, manife return nil } - if a.flags.NoPrompt { + if a.flags.noPrompt { return nil } 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 3fa02facc29..c6d14c9b4a5 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 @@ -81,7 +81,7 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error { // doesn't complete the full agent configuration only to have it discarded. agentYamlPath := filepath.Join(srcDir, "agent.yaml") if _, statErr := os.Stat(agentYamlPath); statErr == nil { - if a.flags.NoPrompt { + if a.flags.noPrompt { return exterrors.Cancelled("agent.yaml already exists; overwrite declined in no-prompt mode") } @@ -469,7 +469,7 @@ func (a *InitFromCodeAction) createDefinitionFromLocalAgent(ctx context.Context) agentKind := agent_yaml.AgentKindHosted // Prompt user for supported protocols - protocols, err := promptProtocols(ctx, a.azdClient.Prompt(), a.flags.NoPrompt, a.flags.protocols) + protocols, err := promptProtocols(ctx, a.azdClient.Prompt(), a.flags.noPrompt, a.flags.protocols) if err != nil { return nil, err } @@ -806,7 +806,7 @@ func (a *InitFromCodeAction) addToProject(ctx context.Context, targetDir string, agentConfig.Deployments = a.deploymentDetails // Detect startup command from the project source directory - startupCmd, err := resolveStartupCommandForInit(ctx, a.azdClient, a.projectConfig.Path, targetDir, a.flags.NoPrompt) + startupCmd, err := resolveStartupCommandForInit(ctx, a.azdClient, a.projectConfig.Path, targetDir, a.flags.noPrompt) if err != nil { return 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 d54c9d27c0d..b9bedd09429 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 @@ -116,7 +116,7 @@ func (a *InitAction) selectFromList( defaultStr = defaultOpt } - if a.flags.NoPrompt { + if a.flags.noPrompt { fmt.Printf("No prompt mode enabled, selecting default %s: %s\n", property, defaultStr) return defaultStr, nil } @@ -356,7 +356,7 @@ func (a *modelSelector) getModelDetails(ctx context.Context, modelName string) ( currentLocation = resolvedLocation } - if a.flags.NoPrompt { + if a.flags.noPrompt { fmt.Println("No prompt mode enabled, automatically selecting a model deployment based on availability and quota...") return resolveModelDeployment(ctx, a.azdClient, a.azureContext, model, currentLocation) } 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 e29cc11cc13..a9b81c8b117 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 @@ -1215,7 +1215,7 @@ func TestApplyPositionalArg_ConflictWithManifestFlag(t *testing.T) { t.Fatalf("failed to create test manifest: %v", err) } - flags := &initFlags{rootFlagsDefinition: &rootFlagsDefinition{}} + flags := &initFlags{} cmd := &cobra.Command{} cmd.Flags().StringVarP(&flags.manifestPointer, "manifest", "m", "", "") // Simulate the user having set --manifest explicitly @@ -1245,7 +1245,7 @@ func TestApplyPositionalArg_ConflictWithSrcFlag(t *testing.T) { tmpDir := t.TempDir() - flags := &initFlags{rootFlagsDefinition: &rootFlagsDefinition{}} + flags := &initFlags{} cmd := &cobra.Command{} cmd.Flags().StringVarP(&flags.src, "src", "s", "", "") cmd.Flags().StringVarP(&flags.manifestPointer, "manifest", "m", "", "") @@ -1279,7 +1279,7 @@ func TestApplyPositionalArg_SetsManifestPointer(t *testing.T) { t.Fatalf("failed to create test manifest: %v", err) } - flags := &initFlags{rootFlagsDefinition: &rootFlagsDefinition{}} + flags := &initFlags{} cmd := &cobra.Command{} cmd.Flags().StringVarP(&flags.manifestPointer, "manifest", "m", "", "") cmd.Flags().StringVarP(&flags.src, "src", "s", "", "") @@ -1297,7 +1297,7 @@ func TestApplyPositionalArg_SetsSrcDir(t *testing.T) { tmpDir := t.TempDir() - flags := &initFlags{rootFlagsDefinition: &rootFlagsDefinition{}} + flags := &initFlags{} cmd := &cobra.Command{} cmd.Flags().StringVarP(&flags.manifestPointer, "manifest", "m", "", "") cmd.Flags().StringVarP(&flags.src, "src", "s", "", "") @@ -1315,7 +1315,7 @@ func TestApplyPositionalArg_NonExistentDirSetsSrc(t *testing.T) { newDir := filepath.Join(t.TempDir(), "new-project") - flags := &initFlags{rootFlagsDefinition: &rootFlagsDefinition{}} + flags := &initFlags{} cmd := &cobra.Command{} cmd.Flags().StringVarP(&flags.manifestPointer, "manifest", "m", "", "") cmd.Flags().StringVarP(&flags.src, "src", "s", "", "") @@ -1333,7 +1333,7 @@ func TestApplyPositionalArg_NonExistentYamlSetsManifest(t *testing.T) { yamlPath := filepath.Join(t.TempDir(), "agent.yaml") - flags := &initFlags{rootFlagsDefinition: &rootFlagsDefinition{}} + flags := &initFlags{} cmd := &cobra.Command{} cmd.Flags().StringVarP(&flags.manifestPointer, "manifest", "m", "", "") cmd.Flags().StringVarP(&flags.src, "src", "s", "", "") @@ -1621,7 +1621,7 @@ func TestResolveCollisions_NoCollision(t *testing.T) { t.Chdir(tmpDir) action := &InitAction{ - flags: &initFlags{rootFlagsDefinition: &rootFlagsDefinition{}}, + flags: &initFlags{}, } dir, svc, err := action.resolveCollisions( @@ -1698,9 +1698,7 @@ func TestResolveCollisions_NoPrompt(t *testing.T) { action := &InitAction{ projectConfig: projectCfg, flags: &initFlags{ - rootFlagsDefinition: &rootFlagsDefinition{ - NoPrompt: true, - }, + noPrompt: true, }, } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go index 7599056a000..9f65bffe856 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke.go @@ -41,11 +41,13 @@ type invokeFlags struct { } type InvokeAction struct { - flags *invokeFlags + flags *invokeFlags + noPrompt bool } -func newInvokeCommand() *cobra.Command { +func newInvokeCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &invokeFlags{} + extCtx = ensureExtensionContext(extCtx) cmd := &cobra.Command{ Use: "invoke [name] [message]", @@ -89,8 +91,6 @@ session automatically. Pass --new-session to force a reset.`, Args: cobra.RangeArgs(0, 2), RunE: func(cmd *cobra.Command, args []string) error { ctx := azdext.WithAccessToken(cmd.Context()) - logCleanup := setupDebugLogging(cmd.Flags()) - defer logCleanup() switch len(args) { case 2: @@ -144,7 +144,10 @@ session automatically. Pass --new-session to force a reset.`, } } - action := &InvokeAction{flags: flags} + action := &InvokeAction{ + flags: flags, + noPrompt: extCtx.NoPrompt, + } return action.Run(ctx) }, } @@ -202,11 +205,11 @@ func (a *InvokeAction) resolveProtocol( if a.flags.local { return resolveAgentProtocol( - ctx, azdClient, "", rootFlags.NoPrompt, + ctx, azdClient, "", a.noPrompt, ) } return resolveAgentProtocol( - ctx, azdClient, a.flags.name, rootFlags.NoPrompt, + ctx, azdClient, a.flags.name, a.noPrompt, ) } @@ -257,7 +260,7 @@ func (a *InvokeAction) responsesLocal(ctx context.Context) error { defer azdClient.Close() } - agentKey := resolveLocalAgentKey(ctx, azdClient, a.flags.name, rootFlags.NoPrompt) + agentKey := resolveLocalAgentKey(ctx, azdClient, a.flags.name, a.noPrompt) // Resolve local session and conversation IDs (always generated locally). var sid, convID string @@ -350,7 +353,7 @@ func (a *InvokeAction) responsesRemote(ctx context.Context) error { var agentEndpoint string // Auto-resolve agent name and version from azure.yaml - if info, err := resolveAgentServiceFromProject(ctx, azdClient, name, rootFlags.NoPrompt); err == nil { + if info, err := resolveAgentServiceFromProject(ctx, azdClient, name, a.noPrompt); err == nil { if name == "" && info.AgentName != "" { name = info.AgentName } @@ -495,7 +498,7 @@ func (a *InvokeAction) invocationsLocal(ctx context.Context) error { defer azdClient.Close() } - agentKey := resolveLocalAgentKey(ctx, azdClient, a.flags.name, rootFlags.NoPrompt) + agentKey := resolveLocalAgentKey(ctx, azdClient, a.flags.name, a.noPrompt) // Resolve local session ID (generated locally, not server-assigned). var sid string @@ -562,7 +565,7 @@ func (a *InvokeAction) invocationsRemote(ctx context.Context) error { var agentEndpoint string // Auto-resolve agent name from azure.yaml / azd environment - if info, err := resolveAgentServiceFromProject(ctx, azdClient, name, rootFlags.NoPrompt); err == nil { + if info, err := resolveAgentServiceFromProject(ctx, azdClient, name, a.noPrompt); err == nil { if name == "" && info.AgentName != "" { name = info.AgentName } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go index 38695480a99..b5bdda30971 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/invoke_test.go @@ -322,7 +322,7 @@ func TestProtocolFlagValidation(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - cmd := newInvokeCommand() + cmd := newInvokeCommand(nil) cmd.SetArgs(tt.args) err := cmd.Execute() if tt.wantErr { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index b40a2eecbed..045bf26fa71 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -24,7 +24,6 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/braydonk/yaml" "github.com/drone/envsubst" - "github.com/spf13/cobra" "google.golang.org/protobuf/types/known/structpb" ) @@ -32,55 +31,33 @@ import ( // digit, used to sanitize environment variable key segments. var nonAlphanumEnvKeyRe = regexp.MustCompile(`[^A-Z0-9]+`) -func newListenCommand() *cobra.Command { - return &cobra.Command{ - Use: "listen", - Short: "Starts the extension and listens for events.", - Hidden: true, - RunE: func(cmd *cobra.Command, args []string) error { - // Create a new context that includes the AZD access token. - ctx := azdext.WithAccessToken(cmd.Context()) - - logCleanup := setupDebugLogging(cmd.Flags()) - defer logCleanup() - - // Create a new AZD client. - azdClient, err := azdext.NewAzdClient() - if err != nil { - return fmt.Errorf("failed to create azd client: %w", err) - } - defer azdClient.Close() - - // IMPORTANT: service target name here must match the name used in the extension manifest. - host := azdext.NewExtensionHost(azdClient). - WithServiceTarget(AiAgentHost, func() azdext.ServiceTargetProvider { - return project.NewAgentServiceTargetProvider(azdClient) - }). - WithProjectEventHandler("preprovision", func(ctx context.Context, args *azdext.ProjectEventArgs) error { - return preprovisionHandler(ctx, azdClient, args) - }). - WithProjectEventHandler("postprovision", func(ctx context.Context, args *azdext.ProjectEventArgs) error { - return postprovisionHandler(ctx, azdClient, args) - }). - WithProjectEventHandler("predeploy", func(ctx context.Context, args *azdext.ProjectEventArgs) error { - return predeployHandler(ctx, azdClient, args) - }). - WithProjectEventHandler("postdeploy", func(ctx context.Context, args *azdext.ProjectEventArgs) error { - return postdeployHandler(ctx, azdClient, args) - }). - WithProjectEventHandler("postdown", func(ctx context.Context, args *azdext.ProjectEventArgs) error { - return postdownHandler(ctx, azdClient, args) - }) - - // Start listening for events - // This is a blocking call and will not return until the server connection is closed. - if err := host.Run(ctx); err != nil { - return fmt.Errorf("failed to run extension: %w", err) - } - - return nil - }, - } +// configureExtensionHost wires the service target and event handlers on the +// supplied [azdext.ExtensionHost]. It is passed to [azdext.NewListenCommand] +// from the root command, which handles the surrounding setup (access token, +// AzdClient creation, and host.Run lifecycle). +func configureExtensionHost(host *azdext.ExtensionHost) { + azdClient := host.Client() + + // IMPORTANT: service target name here must match the name used in the extension manifest. + host. + WithServiceTarget(AiAgentHost, func() azdext.ServiceTargetProvider { + return project.NewAgentServiceTargetProvider(azdClient) + }). + WithProjectEventHandler("preprovision", func(ctx context.Context, args *azdext.ProjectEventArgs) error { + return preprovisionHandler(ctx, azdClient, args) + }). + WithProjectEventHandler("postprovision", func(ctx context.Context, args *azdext.ProjectEventArgs) error { + return postprovisionHandler(ctx, azdClient, args) + }). + WithProjectEventHandler("predeploy", func(ctx context.Context, args *azdext.ProjectEventArgs) error { + return predeployHandler(ctx, azdClient, args) + }). + WithProjectEventHandler("postdeploy", func(ctx context.Context, args *azdext.ProjectEventArgs) error { + return postdeployHandler(ctx, azdClient, args) + }). + WithProjectEventHandler("postdown", func(ctx context.Context, args *azdext.ProjectEventArgs) error { + return postdownHandler(ctx, azdClient, args) + }) } func preprovisionHandler(ctx context.Context, azdClient *azdext.AzdClient, args *azdext.ProjectEventArgs) error { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/metadata.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/metadata.go deleted file mode 100644 index f83bb769460..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/metadata.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "encoding/json" - "fmt" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/spf13/cobra" -) - -func newMetadataCommand() *cobra.Command { - return &cobra.Command{ - Use: "metadata", - Short: "Generate extension metadata including command structure and configuration schemas", - Hidden: true, - RunE: func(cmd *cobra.Command, args []string) error { - // Get root command for metadata generation - rootCmd := cmd.Root() - - // Generate extension metadata with commands and configuration - metadata := azdext.GenerateExtensionMetadata( - "1.0", // schema version - "azure.ai.agents", // extension id - rootCmd, - ) - - // Output as JSON - jsonBytes, err := json.MarshalIndent(metadata, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal metadata: %w", err) - } - - fmt.Println(string(jsonBytes)) - return nil - }, - } -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/monitor.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/monitor.go index 79f1177b0fa..422abec77b4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/monitor.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/monitor.go @@ -31,8 +31,9 @@ type MonitorAction struct { flags *monitorFlags } -func newMonitorCommand() *cobra.Command { +func newMonitorCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &monitorFlags{} + extCtx = ensureExtensionContext(extCtx) cmd := &cobra.Command{ Use: "monitor [name]", @@ -73,8 +74,6 @@ configuration and the current azd environment. Optionally specify the service na } ctx := azdext.WithAccessToken(cmd.Context()) - logCleanup := setupDebugLogging(cmd.Flags()) - defer logCleanup() azdClient, err := azdext.NewAzdClient() if err != nil { @@ -82,7 +81,7 @@ configuration and the current azd environment. Optionally specify the service na } defer azdClient.Close() - info, err := resolveAgentServiceFromProject(ctx, azdClient, flags.name, rootFlags.NoPrompt) + info, err := resolveAgentServiceFromProject(ctx, azdClient, flags.name, extCtx.NoPrompt) if err != nil { return err } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/monitor_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/monitor_test.go index 7a0e96a7303..c7d9a4f2d08 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/monitor_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/monitor_test.go @@ -14,19 +14,19 @@ import ( ) func TestMonitorCommand_AcceptsPositionalArg(t *testing.T) { - cmd := newMonitorCommand() + cmd := newMonitorCommand(nil) err := cmd.Args(cmd, []string{"my-agent"}) assert.NoError(t, err) } func TestMonitorCommand_AcceptsNoArgs(t *testing.T) { - cmd := newMonitorCommand() + cmd := newMonitorCommand(nil) err := cmd.Args(cmd, []string{}) assert.NoError(t, err) } func TestMonitorCommand_RejectsMultipleArgs(t *testing.T) { - cmd := newMonitorCommand() + cmd := newMonitorCommand(nil) err := cmd.Args(cmd, []string{"svc1", "svc2"}) assert.Error(t, err) } @@ -88,7 +88,7 @@ func TestValidateMonitorFlags_InvalidType(t *testing.T) { } func TestMonitorCommand_DefaultValues(t *testing.T) { - cmd := newMonitorCommand() + cmd := newMonitorCommand(nil) // Verify default flag values tail, _ := cmd.Flags().GetInt("tail") @@ -105,7 +105,7 @@ func TestMonitorCommand_DefaultValues(t *testing.T) { } func TestMonitorCommand_SessionFlagRegistered(t *testing.T) { - cmd := newMonitorCommand() + cmd := newMonitorCommand(nil) // The --session-id / -s flag must be defined f := cmd.Flags().Lookup("session-id") @@ -114,7 +114,7 @@ func TestMonitorCommand_SessionFlagRegistered(t *testing.T) { } func TestMonitorCommand_FollowFlagRegistered(t *testing.T) { - cmd := newMonitorCommand() + cmd := newMonitorCommand(nil) f := cmd.Flags().Lookup("follow") require.NotNil(t, f, "--follow flag should be registered") diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go index da0a79bd842..d65d1c0b8e5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go @@ -5,30 +5,35 @@ package cmd import ( "fmt" - "os" - "strconv" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/fatih/color" "github.com/spf13/cobra" ) -type rootFlagsDefinition struct { - Debug bool - NoPrompt bool -} - -// Enable access to the global command flags -var rootFlags rootFlagsDefinition - func NewRootCommand() *cobra.Command { - rootCmd := &cobra.Command{ - Use: "agent [options]", - Short: fmt.Sprintf("Ship agents with Microsoft Foundry from your terminal. %s", color.YellowString("(Preview)")), - SilenceUsage: true, - SilenceErrors: true, - CompletionOptions: cobra.CompletionOptions{ - DisableDefaultCmd: true, - }, + rootCmd, extCtx := azdext.NewExtensionRootCommand(azdext.ExtensionCommandOptions{ + Name: "agent", + Use: "agent [options]", + Short: fmt.Sprintf("Ship agents with Microsoft Foundry from your terminal. %s", color.YellowString("(Preview)")), + }) + rootCmd.SilenceUsage = true + rootCmd.SilenceErrors = true + rootCmd.CompletionOptions.DisableDefaultCmd = true + + // Configure debug logging once on the root command so every subcommand + // inherits it (cobra.EnableTraverseRunHooks, set by the SDK, ensures this + // runs alongside any subcommand pre-runs). The cleanup func is intentionally + // discarded: log writes are unbuffered and the OS closes the file on exit. + sdkPreRun := rootCmd.PersistentPreRunE + rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { + if sdkPreRun != nil { + if err := sdkPreRun(cmd, args); err != nil { + return err + } + } + setupDebugLogging(cmd.Flags()) + return nil } // Show the ASCII art banner above the default help text for the root command @@ -41,45 +46,20 @@ func NewRootCommand() *cobra.Command { }) rootCmd.SetHelpCommand(&cobra.Command{Hidden: true}) - rootCmd.PersistentFlags().BoolVar( - &rootFlags.Debug, - "debug", - false, - "Enable debug mode", - ) - - // Adds support for `--no-prompt` global flag in azd - // Without this the extension command will error when the flag is provided - rootCmd.PersistentFlags().BoolVar( - &rootFlags.NoPrompt, - "no-prompt", - false, - "Runs without prompts. Uses existing values; "+ - "fails if any required value or decision cannot be resolved automatically.", - ) - - rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - if !cmd.Flags().Changed("no-prompt") { - if v := os.Getenv("AZD_NO_PROMPT"); v != "" { - if b, err := strconv.ParseBool(v); err == nil { - rootFlags.NoPrompt = b - } - } - } - return nil - } - rootCmd.AddCommand(newListenCommand()) + rootCmd.AddCommand(azdext.NewListenCommand(configureExtensionHost)) rootCmd.AddCommand(newVersionCommand()) - rootCmd.AddCommand(newInitCommand(&rootFlags)) - rootCmd.AddCommand(newRunCommand()) - rootCmd.AddCommand(newInvokeCommand()) + rootCmd.AddCommand(newInitCommand(extCtx)) + rootCmd.AddCommand(newRunCommand(extCtx)) + rootCmd.AddCommand(newInvokeCommand(extCtx)) rootCmd.AddCommand(newMcpCommand()) - rootCmd.AddCommand(newMetadataCommand()) - rootCmd.AddCommand(newShowCommand()) - rootCmd.AddCommand(newMonitorCommand()) - rootCmd.AddCommand(newFilesCommand()) - rootCmd.AddCommand(newSessionCommand()) + rootCmd.AddCommand(azdext.NewMetadataCommand("1.0", "azure.ai.agents", func() *cobra.Command { + return rootCmd + })) + rootCmd.AddCommand(newShowCommand(extCtx)) + rootCmd.AddCommand(newMonitorCommand(extCtx)) + rootCmd.AddCommand(newFilesCommand(extCtx)) + rootCmd.AddCommand(newSessionCommand(extCtx)) return rootCmd } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go index 9648d59a314..ee2ef0b530c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/run.go @@ -26,8 +26,9 @@ type runFlags struct { startCommand string } -func newRunCommand() *cobra.Command { +func newRunCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &runFlags{} + extCtx = ensureExtensionContext(extCtx) cmd := &cobra.Command{ Use: "run [name]", @@ -63,9 +64,7 @@ Use a separate terminal to invoke the running agent: flags.name = args[0] } ctx := azdext.WithAccessToken(cmd.Context()) - logCleanup := setupDebugLogging(cmd.Flags()) - defer logCleanup() - return runRun(ctx, flags) + return runRun(ctx, flags, extCtx.NoPrompt) }, } @@ -76,7 +75,7 @@ Use a separate terminal to invoke the running agent: return cmd } -func runRun(ctx context.Context, flags *runFlags) error { +func runRun(ctx context.Context, flags *runFlags, noPrompt bool) error { azdClient, err := azdext.NewAzdClient() if err != nil { return fmt.Errorf("failed to create azd client: %w", err) @@ -84,14 +83,14 @@ func runRun(ctx context.Context, flags *runFlags) error { defer azdClient.Close() // Resolve the service source directory and startup command from azure.yaml - runCtx, err := resolveServiceRunContext(ctx, azdClient, flags.name, rootFlags.NoPrompt) + runCtx, err := resolveServiceRunContext(ctx, azdClient, flags.name, noPrompt) if err != nil { return err } projectDir := runCtx.ProjectDir // Clean up stored local session when the agent process exits. - localAgentKey := resolveLocalAgentKeyWithPort(ctx, azdClient, runCtx.ServiceName, rootFlags.NoPrompt, flags.port) + localAgentKey := resolveLocalAgentKeyWithPort(ctx, azdClient, runCtx.ServiceName, noPrompt, flags.port) defer func() { if err := deleteContextValue(ctx, azdClient, "sessions", localAgentKey); err != nil { log.Printf("run: failed to clear stored local session: %v", err) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/session.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/session.go index b3d35c8282e..3b4ebab1472 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/session.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/session.go @@ -25,10 +25,13 @@ import ( // sessionFlags holds common flags shared by all session subcommands. type sessionFlags struct { agentName string + noPrompt bool output string } -func newSessionCommand() *cobra.Command { +func newSessionCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + cmd := &cobra.Command{ Use: "sessions", Short: "Manage sessions for a hosted agent endpoint.", @@ -43,27 +46,10 @@ Use --agent-name to select a specific agent when the project has multiple azure.ai.agent services.`, } - // PersistentPreRunE is set outside the struct literal so the closure - // captures the outer cmd variable. When a subcommand runs (e.g. - // "sessions create"), Cobra passes the leaf command as the function - // parameter. Using cmd.Parent() here reaches the root command; - // using the parameter's Parent() would return this session command - // itself, causing infinite recursion. - cmd.PersistentPreRunE = func(childCmd *cobra.Command, args []string) error { - if parent := cmd.Parent(); parent != nil && - parent.PersistentPreRunE != nil { - if err := parent.PersistentPreRunE(childCmd, args); err != nil { - return err - } - } - - return nil - } - - cmd.AddCommand(newSessionCreateCommand()) - cmd.AddCommand(newSessionShowCommand()) - cmd.AddCommand(newSessionDeleteCommand()) - cmd.AddCommand(newSessionListCommand()) + cmd.AddCommand(newSessionCreateCommand(extCtx)) + cmd.AddCommand(newSessionShowCommand(extCtx)) + cmd.AddCommand(newSessionDeleteCommand(extCtx)) + cmd.AddCommand(newSessionListCommand(extCtx)) return cmd } @@ -75,10 +61,6 @@ func addSessionFlags(cmd *cobra.Command, flags *sessionFlags) { "Agent name (matches azure.yaml service name; "+ "auto-detected when only one exists)", ) - cmd.Flags().StringVarP( - &flags.output, "output", "o", "json", - "Output format (json or table)", - ) } // sessionContext holds the resolved agent context for session operations. @@ -91,7 +73,7 @@ type sessionContext struct { // resolveSessionContext resolves the agent name, version, and project endpoint. func resolveSessionContext( - ctx context.Context, agentName string, + ctx context.Context, agentName string, noPrompt bool, ) (*sessionContext, error) { azdClient, err := azdext.NewAzdClient() if err != nil { @@ -104,7 +86,7 @@ func resolveSessionContext( var agentEndpoint string if info, err := resolveAgentServiceFromProject( - ctx, azdClient, name, rootFlags.NoPrompt, + ctx, azdClient, name, noPrompt, ); err == nil { if name == "" && info.AgentName != "" { name = info.AgentName @@ -150,9 +132,10 @@ type sessionCreateFlags struct { isolationKey string } -func newSessionCreateCommand() *cobra.Command { +func newSessionCreateCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &sessionCreateFlags{} action := &SessionCreateAction{flags: flags} + extCtx = ensureExtensionContext(extCtx) cmd := &cobra.Command{ Use: "create [agent-name] [version] [isolation-key]", @@ -185,8 +168,10 @@ Positional arguments can be used instead of flags: azd ai agent sessions create --session-id my-session`, Args: cobra.MaximumNArgs(3), RunE: func(cmd *cobra.Command, args []string) error { + flags.noPrompt = extCtx.NoPrompt + flags.output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) - setupDebugLogging(cmd.Flags()) // Positional args fill in missing flags switch len(args) { @@ -211,6 +196,11 @@ Positional arguments can be used instead of flags: } addSessionFlags(cmd, &flags.sessionFlags) + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "table"}, + Default: "json", + }) cmd.Flags().StringVar( &flags.sessionID, "session-id", "", "Optional caller-provided session ID "+ @@ -236,7 +226,7 @@ type SessionCreateAction struct { } func (a *SessionCreateAction) Run(ctx context.Context) error { - sc, err := resolveSessionContext(ctx, a.flags.agentName) + sc, err := resolveSessionContext(ctx, a.flags.agentName, a.flags.noPrompt) if err != nil { return err } @@ -302,9 +292,10 @@ type sessionShowFlags struct { sessionFlags } -func newSessionShowCommand() *cobra.Command { +func newSessionShowCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &sessionShowFlags{} action := &SessionShowAction{flags: flags} + extCtx = ensureExtensionContext(extCtx) cmd := &cobra.Command{ Use: "show ", @@ -320,8 +311,10 @@ specified session.`, azd ai agent sessions show my-session --output table`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + flags.noPrompt = extCtx.NoPrompt + flags.output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) - setupDebugLogging(cmd.Flags()) action.sessionID = args[0] return action.Run(ctx) @@ -329,6 +322,11 @@ specified session.`, } addSessionFlags(cmd, &flags.sessionFlags) + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "table"}, + Default: "json", + }) return cmd } @@ -340,7 +338,7 @@ type SessionShowAction struct { } func (a *SessionShowAction) Run(ctx context.Context) error { - sc, err := resolveSessionContext(ctx, a.flags.agentName) + sc, err := resolveSessionContext(ctx, a.flags.agentName, a.flags.noPrompt) if err != nil { return err } @@ -388,9 +386,10 @@ type sessionDeleteFlags struct { isolationKey string } -func newSessionDeleteCommand() *cobra.Command { +func newSessionDeleteCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &sessionDeleteFlags{} action := &SessionDeleteAction{flags: flags} + extCtx = ensureExtensionContext(extCtx) cmd := &cobra.Command{ Use: "delete ", @@ -408,8 +407,9 @@ The isolation key is derived from the Entra token by default.`, azd ai agent sessions delete my-session --isolation-key sk-abc123`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { + flags.noPrompt = extCtx.NoPrompt + ctx := azdext.WithAccessToken(cmd.Context()) - setupDebugLogging(cmd.Flags()) action.sessionID = args[0] return action.Run(ctx) @@ -439,7 +439,7 @@ type SessionDeleteAction struct { } func (a *SessionDeleteAction) Run(ctx context.Context) error { - sc, err := resolveSessionContext(ctx, a.flags.agentName) + sc, err := resolveSessionContext(ctx, a.flags.agentName, a.flags.noPrompt) if err != nil { return err } @@ -508,9 +508,10 @@ type sessionListFlags struct { paginationToken string } -func newSessionListCommand() *cobra.Command { +func newSessionListCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &sessionListFlags{} action := &SessionListAction{flags: flags} + extCtx = ensureExtensionContext(extCtx) cmd := &cobra.Command{ Use: "list", @@ -527,8 +528,10 @@ Returns a paged list of sessions with their status, version, and timestamps.`, # List in table format azd ai agent sessions list --output table`, RunE: func(cmd *cobra.Command, args []string) error { + flags.noPrompt = extCtx.NoPrompt + flags.output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) - setupDebugLogging(cmd.Flags()) action.limitChanged = cmd.Flags().Changed("limit") return action.Run(ctx) @@ -536,6 +539,11 @@ Returns a paged list of sessions with their status, version, and timestamps.`, } addSessionFlags(cmd, &flags.sessionFlags) + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "table"}, + Default: "json", + }) cmd.Flags().Int32Var( &flags.limit, "limit", 0, "Maximum number of sessions to return", @@ -555,7 +563,7 @@ type SessionListAction struct { } func (a *SessionListAction) Run(ctx context.Context) error { - sc, err := resolveSessionContext(ctx, a.flags.agentName) + sc, err := resolveSessionContext(ctx, a.flags.agentName, a.flags.noPrompt) if err != nil { return err } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/session_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/session_test.go index 7f20c2e0a8f..ccb616562ae 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/session_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/session_test.go @@ -24,7 +24,7 @@ import ( // --------------------------------------------------------------------------- func TestSessionCommand_HasSubcommands(t *testing.T) { - cmd := newSessionCommand() + cmd := newSessionCommand(nil) names := make([]string, 0, len(cmd.Commands())) for _, sub := range cmd.Commands() { @@ -38,7 +38,7 @@ func TestSessionCommand_HasSubcommands(t *testing.T) { } func TestSessionShowCommand_RequiresOneArg(t *testing.T) { - cmd := newSessionShowCommand() + cmd := newSessionShowCommand(nil) assert.NoError(t, cmd.Args(cmd, []string{"my-session"})) assert.Error(t, cmd.Args(cmd, []string{})) @@ -46,7 +46,7 @@ func TestSessionShowCommand_RequiresOneArg(t *testing.T) { } func TestSessionDeleteCommand_RequiresOneArg(t *testing.T) { - cmd := newSessionDeleteCommand() + cmd := newSessionDeleteCommand(nil) assert.NoError(t, cmd.Args(cmd, []string{"my-session"})) assert.Error(t, cmd.Args(cmd, []string{})) @@ -54,10 +54,9 @@ func TestSessionDeleteCommand_RequiresOneArg(t *testing.T) { } func TestSessionCreateCommand_DefaultFlags(t *testing.T) { - cmd := newSessionCreateCommand() + cmd := newSessionCreateCommand(nil) - output, _ := cmd.Flags().GetString("output") - assert.Equal(t, "json", output) + assertOutputFlagOptions(t, cmd, "json", []string{"json", "table"}) sessionID, _ := cmd.Flags().GetString("session-id") assert.Equal(t, "", sessionID) @@ -70,7 +69,7 @@ func TestSessionCreateCommand_DefaultFlags(t *testing.T) { } func TestSessionCreateCommand_AcceptsPositionalArgs(t *testing.T) { - cmd := newSessionCreateCommand() + cmd := newSessionCreateCommand(nil) assert.NoError(t, cmd.Args(cmd, []string{})) assert.NoError(t, cmd.Args(cmd, []string{"my-agent"})) @@ -80,10 +79,9 @@ func TestSessionCreateCommand_AcceptsPositionalArgs(t *testing.T) { } func TestSessionListCommand_DefaultFlags(t *testing.T) { - cmd := newSessionListCommand() + cmd := newSessionListCommand(nil) - output, _ := cmd.Flags().GetString("output") - assert.Equal(t, "json", output) + assertOutputFlagOptions(t, cmd, "json", []string{"json", "table"}) limit, _ := cmd.Flags().GetInt32("limit") assert.Equal(t, int32(0), limit) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go index 4e74061e0ad..2916fe4ab86 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show.go @@ -36,8 +36,9 @@ type ShowAction struct { serviceKey string } -func newShowCommand() *cobra.Command { +func newShowCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &showFlags{} + extCtx = ensureExtensionContext(extCtx) cmd := &cobra.Command{ Use: "show [name]", @@ -60,9 +61,9 @@ configuration and the current azd environment. Optionally specify the service na if len(args) > 0 { flags.name = args[0] } + flags.output = extCtx.OutputFormat + ctx := azdext.WithAccessToken(cmd.Context()) - logCleanup := setupDebugLogging(cmd.Flags()) - defer logCleanup() azdClient, err := azdext.NewAzdClient() if err != nil { @@ -70,7 +71,7 @@ configuration and the current azd environment. Optionally specify the service na } defer azdClient.Close() - info, err := resolveAgentServiceFromProject(ctx, azdClient, flags.name, rootFlags.NoPrompt) + info, err := resolveAgentServiceFromProject(ctx, azdClient, flags.name, extCtx.NoPrompt) if err != nil { return err } @@ -113,7 +114,11 @@ configuration and the current azd environment. Optionally specify the service na }, } - cmd.Flags().StringVarP(&flags.output, "output", "o", "json", "Output format (json or table)") + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "table"}, + Default: "json", + }) return cmd } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go index 5a45044cdd7..955d4021331 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/show_test.go @@ -14,19 +14,19 @@ import ( ) func TestShowCommand_AcceptsPositionalArg(t *testing.T) { - cmd := newShowCommand() + cmd := newShowCommand(nil) err := cmd.Args(cmd, []string{"my-agent"}) assert.NoError(t, err) } func TestShowCommand_AcceptsNoArgs(t *testing.T) { - cmd := newShowCommand() + cmd := newShowCommand(nil) err := cmd.Args(cmd, []string{}) assert.NoError(t, err) } func TestShowCommand_RejectsMultipleArgs(t *testing.T) { - cmd := newShowCommand() + cmd := newShowCommand(nil) err := cmd.Args(cmd, []string{"svc1", "svc2"}) assert.Error(t, err) } @@ -67,11 +67,9 @@ func TestNewAgentContext_PartialFlags(t *testing.T) { assert.Contains(t, err.Error(), "both --account-name and --project-name must be provided together") } -func TestShowCommand_DefaultOutputFlag(t *testing.T) { - cmd := newShowCommand() - - output, _ := cmd.Flags().GetString("output") - assert.Equal(t, "json", output) +func TestShowCommand_DefaultOutputFormat(t *testing.T) { + cmd := newShowCommand(nil) + assertOutputFlagOptions(t, cmd, "json", []string{"json", "table"}) } func TestPrintAgentVersionJSON(t *testing.T) {