From 3c255fc8f6e69daa9a37984006ba746e3f95180d Mon Sep 17 00:00:00 2001 From: huimiu Date: Tue, 19 May 2026 17:07:41 +0800 Subject: [PATCH 1/9] feat(azure.ai.projects): migrate project endpoint commands from azure.ai.agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the project endpoint set/unset/show logic introduced in #8162 (originally added to `azure.ai.agents` as `azd ai agent project ...`) into the new `azure.ai.projects` extension. Subcommands hang directly off the extension root (which is already `project`), so users get: - `azd ai project set ` — persist a default Foundry project endpoint - `azd ai project unset` — clear the persisted endpoint - `azd ai project show` — show the resolved endpoint and source Key adjustments vs source PR: - Module path `azure.ai.projects` - Config namespace `extensions.ai-projects.context` (independent of the agents extension's store) - Suggestion strings reference `azd ai project set` - 5-level resolver split into its own `project_resolver.go` The resolver still implements the spec'd cascade: flag → active azd env (`AZURE_AI_PROJECT_ENDPOINT`) → global config → host `FOUNDRY_PROJECT_ENDPOINT` → structured `missing_project_endpoint` error. Invalid values at any level are hard validation errors (no silent fallback). A minimal `internal/exterrors` package is introduced with only the `Validation` / `Dependency` factories and codes required by the migrated commands. --- cli/azd/extensions/azure.ai.projects/go.mod | 8 +- cli/azd/extensions/azure.ai.projects/go.sum | 2 - .../internal/cmd/extension_context.go | 18 ++ .../internal/cmd/flag_options_test.go | 32 +++ .../internal/cmd/project_context_store.go | 101 +++++++++ .../internal/cmd/project_endpoint.go | 134 ++++++++++++ .../internal/cmd/project_endpoint_test.go | 108 ++++++++++ .../internal/cmd/project_resolver.go | 181 ++++++++++++++++ .../internal/cmd/project_resolver_test.go | 200 ++++++++++++++++++ .../internal/cmd/project_set.go | 118 +++++++++++ .../internal/cmd/project_set_test.go | 24 +++ .../internal/cmd/project_show.go | 142 +++++++++++++ .../internal/cmd/project_show_test.go | 92 ++++++++ .../internal/cmd/project_unset.go | 90 ++++++++ .../internal/cmd/project_unset_test.go | 28 +++ .../azure.ai.projects/internal/cmd/root.go | 3 + .../internal/exterrors/codes.go | 19 ++ .../internal/exterrors/errors.go | 39 ++++ 18 files changed, 1332 insertions(+), 7 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/extension_context.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/flag_options_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_set.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_set_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_show_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_unset.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_unset_test.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go create mode 100644 cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go diff --git a/cli/azd/extensions/azure.ai.projects/go.mod b/cli/azd/extensions/azure.ai.projects/go.mod index 4f724b0d5d0..fb1af3c91ec 100644 --- a/cli/azd/extensions/azure.ai.projects/go.mod +++ b/cli/azd/extensions/azure.ai.projects/go.mod @@ -1,19 +1,19 @@ module azure.ai.projects - go 1.26.1 require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 github.com/azure/azure-dev/cli/azd v1.25.0 github.com/fatih/color v1.18.0 github.com/spf13/cobra v1.10.1 + github.com/stretchr/testify v1.11.1 + google.golang.org/grpc v1.80.0 ) require ( github.com/AlecAivazis/survey/v2 v2.3.7 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 // indirect @@ -76,7 +76,6 @@ require ( github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/theckman/yacspin v0.13.12 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect @@ -98,7 +97,6 @@ require ( golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/cli/azd/extensions/azure.ai.projects/go.sum b/cli/azd/extensions/azure.ai.projects/go.sum index 09262a16074..2f50b8bcfd1 100644 --- a/cli/azd/extensions/azure.ai.projects/go.sum +++ b/cli/azd/extensions/azure.ai.projects/go.sum @@ -15,8 +15,6 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsI github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0 h1:pPvTJ1dY0sA35JOeFq6TsY2xj6Z85Yo23Pj4wCCvu4o= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0/go.mod h1:mLfWfj8v3jfWKsL9G4eoBoXVcsqcIUTapmdKy7uGOp0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY= diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/extension_context.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/extension_context.go new file mode 100644 index 00000000000..93dd4d56487 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/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.projects/internal/cmd/flag_options_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/flag_options_test.go new file mode 100644 index 00000000000..7d1abb3d95d --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/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.projects/internal/cmd/project_context_store.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go new file mode 100644 index 00000000000..e81031712ec --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go @@ -0,0 +1,101 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// projectContextConfigPath is the UserConfig path for the persisted project context. +const projectContextConfigPath = configPathPrefix + ".context" + +// projectContextState is the JSON shape stored at extensions.ai-projects.context +// in ~/.azd/config.json. +type projectContextState struct { + Endpoint string `json:"endpoint"` + SetAt string `json:"setAt"` +} + +// getProjectContext reads the persisted project context from global config. +// Returns (state, true, nil) when present, (zero, false, nil) when absent. +func getProjectContext( + ctx context.Context, + azdClient *azdext.AzdClient, +) (projectContextState, bool, error) { + ch, err := azdext.NewConfigHelper(azdClient) + if err != nil { + return projectContextState{}, false, fmt.Errorf("getProjectContext: %w", err) + } + + var state projectContextState + found, err := ch.GetUserJSON(ctx, projectContextConfigPath, &state) + if err != nil { + return projectContextState{}, false, + fmt.Errorf("getProjectContext: failed to read config: %w", err) + } + + if !found || state.Endpoint == "" { + return projectContextState{}, false, nil + } + + return state, true, nil +} + +// setProjectContext persists a validated project endpoint to global config. +// The caller is responsible for validating the endpoint before calling this function. +// Returns the setAt timestamp that was written to config. +func setProjectContext( + ctx context.Context, + azdClient *azdext.AzdClient, + endpoint string, +) (setAt string, err error) { + ch, chErr := azdext.NewConfigHelper(azdClient) + if chErr != nil { + return "", fmt.Errorf("setProjectContext: %w", chErr) + } + + state := projectContextState{ + Endpoint: endpoint, + SetAt: time.Now().UTC().Format(time.RFC3339), + } + + if err := ch.SetUserJSON(ctx, projectContextConfigPath, state); err != nil { + return "", fmt.Errorf("setProjectContext: failed to write config: %w", err) + } + + return state.SetAt, nil +} + +// clearProjectContext removes the context subtree from global config. +// Returns the previously stored endpoint (empty if none was set). +// The operation is idempotent — calling it when no context is set is not an error. +func clearProjectContext( + ctx context.Context, + azdClient *azdext.AzdClient, +) (previousEndpoint string, err error) { + // Read existing state first so we can return the previous endpoint. + state, found, err := getProjectContext(ctx, azdClient) + if err != nil { + return "", err + } + + if found { + previousEndpoint = state.Endpoint + } + + ch, chErr := azdext.NewConfigHelper(azdClient) + if chErr != nil { + return "", fmt.Errorf("clearProjectContext: %w", chErr) + } + + if err := ch.UnsetUser(ctx, projectContextConfigPath); err != nil { + return "", fmt.Errorf("clearProjectContext: failed to clear config: %w", err) + } + + return previousEndpoint, nil +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint.go new file mode 100644 index 00000000000..b586f760cb3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint.go @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "net/url" + "strings" + + "azure.ai.projects/internal/exterrors" +) + +// configPathPrefix is the UserConfig namespace for the Foundry project context +// persisted by this extension (stored in ~/.azd/config.json). +const configPathPrefix = "extensions.ai-projects" + +// EndpointSource identifies where the resolved project endpoint came from. +type EndpointSource string + +const ( + // SourceFlag means the endpoint came from a --project-endpoint flag (level 1). + SourceFlag EndpointSource = "flag" + // SourceAzdEnv means the endpoint came from the active azd environment's + // AZURE_AI_PROJECT_ENDPOINT value (level 2). + SourceAzdEnv EndpointSource = "azdEnv" + // SourceGlobalConfig means the endpoint came from ~/.azd/config.json + // (extensions.ai-projects.context.endpoint) (level 3). + SourceGlobalConfig EndpointSource = "globalConfig" + // SourceFoundryEnv means the endpoint came from the FOUNDRY_PROJECT_ENDPOINT + // host environment variable (level 4). + SourceFoundryEnv EndpointSource = "foundryEnv" +) + +// foundryHostSuffixes is the authoritative list of accepted Foundry host suffixes. +var foundryHostSuffixes = []string{ + ".services.ai.azure.com", +} + +// projectEndpointPathPrefix is the expected path prefix for Foundry project endpoints. +const projectEndpointPathPrefix = "/api/projects/" + +// isFoundryHost reports whether the hostname ends with one of the recognized +// Foundry host suffixes. +func isFoundryHost(hostname string) bool { + h := strings.ToLower(hostname) + for _, suffix := range foundryHostSuffixes { + if strings.HasSuffix(h, suffix) { + return true + } + } + return false +} + +// validateProjectEndpoint validates and normalizes a Foundry project endpoint URL. +// +// The URL must be an absolute https:// URL whose host ends with a recognized +// Foundry suffix (see [foundryHostSuffixes]). Whitespace is trimmed, trailing +// slashes are stripped, and the result is returned in normalized form. +// +// The second return value is true when the path does not look like +// /api/projects/ — callers may use this as a non-fatal warning. +func validateProjectEndpoint(raw string) (normalized string, pathWarning bool, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", false, exterrors.Validation( + exterrors.CodeInvalidParameter, + "project endpoint must not be empty", + "provide a Foundry project endpoint URL "+ + "(e.g. https://.services.ai.azure.com/api/projects/)", + ) + } + + u, parseErr := url.Parse(raw) + if parseErr != nil { + return "", false, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("invalid project endpoint URL: %v", parseErr), + "provide a valid https:// Foundry project endpoint URL", + ) + } + + if !strings.EqualFold(u.Scheme, "https") { + return "", false, exterrors.Validation( + exterrors.CodeInvalidParameter, + "project endpoint must use https", + "provide an https:// URL", + ) + } + + host := u.Hostname() + if host == "" || !isFoundryHost(host) { + return "", false, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf( + "project endpoint host %q is not a recognized Foundry host (*%s)", + host, foundryHostSuffixes[0], + ), + "the host must end with "+foundryHostSuffixes[0], + ) + } + + if u.Port() != "" { + return "", false, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("project endpoint host %q must not include a port", u.Host), + "remove the explicit port from the URL", + ) + } + + // Normalize: lowercase host, strip trailing slash. + path := strings.TrimRight(u.EscapedPath(), "/") + normalized = fmt.Sprintf("https://%s%s", strings.ToLower(host), path) + + // Warn when the path does not look like /api/projects/. + if !strings.HasPrefix(path, projectEndpointPathPrefix) || + strings.TrimPrefix(path, projectEndpointPathPrefix) == "" { + pathWarning = true + } + + return normalized, pathWarning, nil +} + +// noProjectEndpointError returns the structured dependency error used when no +// project endpoint could be resolved from any source. +func noProjectEndpointError() error { + return exterrors.Dependency( + exterrors.CodeMissingProjectEndpoint, + "no Foundry project endpoint resolved", + "persist a workspace default with `azd ai project set `, "+ + "or set AZURE_AI_PROJECT_ENDPOINT in the active azd environment, "+ + "or export FOUNDRY_PROJECT_ENDPOINT in your shell", + ) +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint_test.go new file mode 100644 index 00000000000..de1ef6777d7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint_test.go @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "azure.ai.projects/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateProjectEndpoint_ValidURLs(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + want string + wantWarning bool + }{ + { + name: "canonical URL", + input: "https://my-acct.services.ai.azure.com/api/projects/my-proj", + want: "https://my-acct.services.ai.azure.com/api/projects/my-proj", + }, + { + name: "trailing slash stripped", + input: "https://my-acct.services.ai.azure.com/api/projects/my-proj/", + want: "https://my-acct.services.ai.azure.com/api/projects/my-proj", + }, + { + name: "whitespace trimmed", + input: " https://my-acct.services.ai.azure.com/api/projects/my-proj ", + want: "https://my-acct.services.ai.azure.com/api/projects/my-proj", + }, + { + name: "uppercase host normalized", + input: "https://MY-ACCT.SERVICES.AI.AZURE.COM/api/projects/my-proj", + want: "https://my-acct.services.ai.azure.com/api/projects/my-proj", + }, + { + name: "missing /api/projects path warns", + input: "https://my-acct.services.ai.azure.com", + want: "https://my-acct.services.ai.azure.com", + wantWarning: true, + }, + { + name: "partial path warns", + input: "https://my-acct.services.ai.azure.com/api/projects/", + want: "https://my-acct.services.ai.azure.com/api/projects", + wantWarning: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, warn, err := validateProjectEndpoint(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantWarning, warn) + }) + } +} + +func TestValidateProjectEndpoint_Rejections(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + }{ + {"empty", ""}, + {"whitespace only", " "}, + {"http scheme", "http://my-acct.services.ai.azure.com/api/projects/p"}, + {"non-foundry host", "https://example.com/api/projects/p"}, + {"explicit port", "https://my-acct.services.ai.azure.com:8080/api/projects/p"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, _, err := validateProjectEndpoint(tt.input) + require.Error(t, err) + var localErr *azdext.LocalError + assert.ErrorAs(t, err, &localErr) + }) + } +} + +func TestIsFoundryHost(t *testing.T) { + t.Parallel() + assert.True(t, isFoundryHost("my-acct.services.ai.azure.com")) + assert.True(t, isFoundryHost("MY-ACCT.SERVICES.AI.AZURE.COM")) + assert.False(t, isFoundryHost("example.com")) + assert.False(t, isFoundryHost("")) +} + +func TestNoProjectEndpointError(t *testing.T) { + t.Parallel() + err := noProjectEndpointError() + require.Error(t, err) + + var localErr *azdext.LocalError + require.ErrorAs(t, err, &localErr) + assert.Equal(t, exterrors.CodeMissingProjectEndpoint, localErr.Code) + assert.Equal(t, azdext.LocalErrorCategoryDependency, localErr.Category) +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go new file mode 100644 index 00000000000..c89a5692805 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "os" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// resolveProjectEndpointOpts controls the 5-level endpoint resolution cascade. +type resolveProjectEndpointOpts struct { + // FlagValue is the value of an explicit endpoint flag (level 1). + // Empty means the flag was not provided. + FlagValue string +} + +// resolvedEndpoint holds the result of resolveProjectEndpoint. +type resolvedEndpoint struct { + Endpoint string + Source EndpointSource + AzdEnvName string + SetAt string // RFC3339 timestamp, only meaningful when Source == SourceGlobalConfig +} + +// azdHostedSources holds the values that the resolver reads from azd-managed +// sources (the active azd environment and ~/.azd/config.json). It is returned +// as a single struct so that tests can stub the whole lookup via +// readAzdHostedSourcesFunc. +type azdHostedSources struct { + // EnvValue is the AZURE_AI_PROJECT_ENDPOINT value from the active azd + // env, or "" if not set / no active env / no azd client available. + EnvValue string + // EnvName is the active azd env name. Only meaningful when EnvValue != "". + EnvName string + // CfgState is the project context persisted in global config. + CfgState projectContextState + // CfgFound indicates whether a non-empty endpoint was found in global config. + CfgFound bool +} + +// readAzdHostedSourcesFunc is a package-level seam so tests can stub the +// daemon-backed lookup without spinning up a real azd gRPC server. +var readAzdHostedSourcesFunc = readAzdHostedSources + +// readAzdHostedSources dials the azd daemon (if reachable) and reads both +// the active env's AZURE_AI_PROJECT_ENDPOINT and the global-config project +// context in a single client lifetime. Errors talking to the daemon are +// returned only for non-Unavailable cases on the config read — Unavailable +// is treated as "no daemon" and the caller falls through to subsequent levels. +func readAzdHostedSources(ctx context.Context) (azdHostedSources, error) { + var out azdHostedSources + + azdClient, err := azdext.NewAzdClient() + if err != nil { + // No azd client at all => no hosted sources, not an error. + return out, nil + } + defer azdClient.Close() + + if envResp, err := azdClient.Environment().GetCurrent( + ctx, &azdext.EmptyRequest{}, + ); err == nil { + envVal, valErr := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ + EnvName: envResp.Environment.Name, + Key: "AZURE_AI_PROJECT_ENDPOINT", + }) + if valErr == nil && envVal.Value != "" { + out.EnvValue = envVal.Value + out.EnvName = envResp.Environment.Name + } + } + + state, found, cfgErr := getProjectContext(ctx, azdClient) + if cfgErr != nil { + // A gRPC Unavailable code means the azd daemon is not reachable; + // treat it the same as azdClient creation failing and fall through + // to the host-environment level. Any other error (e.g. parse + // failure) is a hard error that callers should surface. + if !containsGRPCCode(cfgErr, codes.Unavailable) { + return out, cfgErr + } + } else { + out.CfgState = state + out.CfgFound = found + } + + return out, nil +} + +// containsGRPCCode walks the error chain looking for a gRPC status with the +// specified code. Because fmt.Errorf("%w", ...) wraps errors without forwarding +// the GRPCStatus() method, we must unwrap manually. +// Note: only follows errors.Unwrap chains; errors.Join multi-wraps are not traversed. +func containsGRPCCode(err error, code codes.Code) bool { + for ; err != nil; err = errors.Unwrap(err) { + if st, ok := status.FromError(err); ok && st.Code() == code { + return true + } + } + return false +} + +// resolveProjectEndpoint resolves a Foundry project endpoint using the 5-level +// cascade defined in the design spec: +// +// 1. Explicit flag (--project-endpoint, if exposed by the caller) +// 2. Active azd env value (AZURE_AI_PROJECT_ENDPOINT) +// 3. Global config: extensions.ai-projects.context.endpoint in ~/.azd/config.json +// 4. Host environment variable FOUNDRY_PROJECT_ENDPOINT +// 5. Structured error with actionable suggestion +// +// Invalid values at any level produce a hard validation error (no silent fallback). +func resolveProjectEndpoint( + ctx context.Context, + opts resolveProjectEndpointOpts, +) (*resolvedEndpoint, error) { + // Level 1: explicit flag. + if opts.FlagValue != "" { + normalized, _, err := validateProjectEndpoint(opts.FlagValue) + if err != nil { + return nil, err + } + return &resolvedEndpoint{ + Endpoint: normalized, + Source: SourceFlag, + }, nil + } + + // Levels 2 + 3: azd-hosted sources (active env, then global config). + sources, err := readAzdHostedSourcesFunc(ctx) + if err != nil { + return nil, err + } + + // Level 2: active azd environment's AZURE_AI_PROJECT_ENDPOINT. + if sources.EnvValue != "" { + normalized, _, err := validateProjectEndpoint(sources.EnvValue) + if err != nil { + return nil, err + } + return &resolvedEndpoint{ + Endpoint: normalized, + Source: SourceAzdEnv, + AzdEnvName: sources.EnvName, + }, nil + } + + // Level 3: global config (~/.azd/config.json). + if sources.CfgFound && sources.CfgState.Endpoint != "" { + normalized, _, err := validateProjectEndpoint(sources.CfgState.Endpoint) + if err != nil { + return nil, err + } + return &resolvedEndpoint{ + Endpoint: normalized, + Source: SourceGlobalConfig, + SetAt: sources.CfgState.SetAt, + }, nil + } + + // Level 4: host environment variable FOUNDRY_PROJECT_ENDPOINT. + if envVal := os.Getenv("FOUNDRY_PROJECT_ENDPOINT"); envVal != "" { + normalized, _, err := validateProjectEndpoint(envVal) + if err != nil { + return nil, err + } + return &resolvedEndpoint{ + Endpoint: normalized, + Source: SourceFoundryEnv, + }, nil + } + + // Level 5: structured error. + return nil, noProjectEndpointError() +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver_test.go new file mode 100644 index 00000000000..ebc92d90d17 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver_test.go @@ -0,0 +1,200 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "testing" + + "azure.ai.projects/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubAzdHostedSources replaces readAzdHostedSourcesFunc for the duration of +// the test with a function that returns the given sources/err. +func stubAzdHostedSources(t *testing.T, sources azdHostedSources, err error) { + t.Helper() + orig := readAzdHostedSourcesFunc + readAzdHostedSourcesFunc = func(context.Context) (azdHostedSources, error) { + return sources, err + } + t.Cleanup(func() { readAzdHostedSourcesFunc = orig }) +} + +// isolateFromAzdDaemon makes the test independent of any azd daemon that +// might be reachable on the developer machine via AZD_SERVER. It does two +// things: +// - Clears AZD_SERVER so azdext.NewAzdClient() cannot connect. +// - Stubs readAzdHostedSourcesFunc to return no hosted sources. +// +// Together this ensures the resolver under test only sees the flag and the +// FOUNDRY_PROJECT_ENDPOINT host env var. +func isolateFromAzdDaemon(t *testing.T) { + t.Helper() + t.Setenv("AZD_SERVER", "") + stubAzdHostedSources(t, azdHostedSources{}, nil) +} + +func TestResolveProjectEndpoint_FlagWins(t *testing.T) { + // Even with FOUNDRY_PROJECT_ENDPOINT and azd-hosted sources set, the flag should win. + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://env.services.ai.azure.com/api/projects/env-proj") + stubAzdHostedSources(t, azdHostedSources{ + EnvValue: "https://azdenv.services.ai.azure.com/api/projects/p", + EnvName: "dev", + }, nil) + + result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + FlagValue: "https://flag.services.ai.azure.com/api/projects/flag-proj", + }) + require.NoError(t, err) + assert.Equal(t, "https://flag.services.ai.azure.com/api/projects/flag-proj", result.Endpoint) + assert.Equal(t, SourceFlag, result.Source) +} + +func TestResolveProjectEndpoint_AzdEnvResolves(t *testing.T) { + // Level 2: AZURE_AI_PROJECT_ENDPOINT from the active azd env wins over + // global config and FOUNDRY_PROJECT_ENDPOINT. + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") + stubAzdHostedSources(t, azdHostedSources{ + EnvValue: " HTTPS://Azdenv.Services.AI.Azure.com/api/projects/p/ ", + EnvName: "dev", + CfgState: projectContextState{ + Endpoint: "https://cfg.services.ai.azure.com/api/projects/p", + SetAt: "2025-01-01T00:00:00Z", + }, + CfgFound: true, + }, nil) + + result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + require.NoError(t, err) + assert.Equal(t, "https://azdenv.services.ai.azure.com/api/projects/p", result.Endpoint) + assert.Equal(t, SourceAzdEnv, result.Source) + assert.Equal(t, "dev", result.AzdEnvName) +} + +func TestResolveProjectEndpoint_AzdEnvInvalidRejected(t *testing.T) { + // Level 2 invalid values are hard errors (no silent fallback to lower levels). + // FOUNDRY_PROJECT_ENDPOINT is set, but resolver must NOT fall through to it. + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") + stubAzdHostedSources(t, azdHostedSources{ + EnvValue: "http://not-https.services.ai.azure.com/api/projects/p", + EnvName: "dev", + }, nil) + + _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + require.Error(t, err) + + var localErr *azdext.LocalError + require.ErrorAs(t, err, &localErr) + assert.Contains(t, localErr.Message, "https") +} + +func TestResolveProjectEndpoint_GlobalConfigResolves(t *testing.T) { + // Level 3: global config wins over FOUNDRY_PROJECT_ENDPOINT when no azd env value is set. + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") + stubAzdHostedSources(t, azdHostedSources{ + CfgState: projectContextState{ + Endpoint: " HTTPS://Cfg.Services.AI.Azure.com/api/projects/p/ ", + SetAt: "2025-01-02T03:04:05Z", + }, + CfgFound: true, + }, nil) + + result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + require.NoError(t, err) + assert.Equal(t, "https://cfg.services.ai.azure.com/api/projects/p", result.Endpoint) + assert.Equal(t, SourceGlobalConfig, result.Source) + assert.Equal(t, "2025-01-02T03:04:05Z", result.SetAt) +} + +func TestResolveProjectEndpoint_GlobalConfigInvalidRejected(t *testing.T) { + // Level 3 invalid values are hard errors (no silent fallback to level 4). + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") + stubAzdHostedSources(t, azdHostedSources{ + CfgState: projectContextState{ + Endpoint: "http://not-https.services.ai.azure.com/api/projects/p", + SetAt: "2025-01-02T03:04:05Z", + }, + CfgFound: true, + }, nil) + + _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + require.Error(t, err) + + var localErr *azdext.LocalError + require.ErrorAs(t, err, &localErr) + assert.Contains(t, localErr.Message, "https") +} + +func TestResolveProjectEndpoint_HostedSourcesErrorPropagates(t *testing.T) { + // Non-recoverable errors from the hosted-source lookup (e.g. config parse + // failure) must be surfaced and must not silently fall through to level 4. + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") + sentinel := errors.New("boom") + stubAzdHostedSources(t, azdHostedSources{}, sentinel) + + _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + require.ErrorIs(t, err, sentinel) +} + +func TestResolveProjectEndpoint_FoundryEnvFallback(t *testing.T) { + // No flag, no azd-hosted sources → falls back to FOUNDRY_PROJECT_ENDPOINT. + isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://env.services.ai.azure.com/api/projects/env-proj") + + result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + require.NoError(t, err) + assert.Equal(t, "https://env.services.ai.azure.com/api/projects/env-proj", result.Endpoint) + assert.Equal(t, SourceFoundryEnv, result.Source) +} + +func TestResolveProjectEndpoint_NothingResolvable(t *testing.T) { + isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "") + + _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + require.Error(t, err) + + var localErr *azdext.LocalError + require.ErrorAs(t, err, &localErr) + assert.Equal(t, exterrors.CodeMissingProjectEndpoint, localErr.Code) +} + +func TestResolveProjectEndpoint_InvalidFlagRejected(t *testing.T) { + isolateFromAzdDaemon(t) + _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + FlagValue: "http://not-https.services.ai.azure.com/api/projects/p", + }) + require.Error(t, err) + + var localErr *azdext.LocalError + require.ErrorAs(t, err, &localErr) + assert.Contains(t, localErr.Message, "https") +} + +func TestResolveProjectEndpoint_InvalidFoundryEnvRejected(t *testing.T) { + isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "http://bad.services.ai.azure.com/api/projects/p") + + _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + require.Error(t, err) + + var localErr *azdext.LocalError + require.ErrorAs(t, err, &localErr) + assert.Contains(t, localErr.Message, "https") +} + +func TestResolveProjectEndpoint_FoundryEnvNormalized(t *testing.T) { + isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", " https://X.SERVICES.AI.AZURE.COM/api/projects/p/ ") + + result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + require.NoError(t, err) + assert.Equal(t, "https://x.services.ai.azure.com/api/projects/p", result.Endpoint) + assert.Equal(t, SourceFoundryEnv, result.Source) +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_set.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_set.go new file mode 100644 index 00000000000..37cf53cccd4 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_set.go @@ -0,0 +1,118 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type projectSetFlags struct { + endpoint string + outputFmt string + noPrompt bool +} + +type projectSetResult struct { + Endpoint string `json:"endpoint"` + Source string `json:"source"` + SourceDetail string `json:"sourceDetail"` + SetAt string `json:"setAt"` +} + +// ProjectSetAction is the action for the `set` command. +type ProjectSetAction struct { + flags *projectSetFlags +} + +func newProjectSetCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + flags := &projectSetFlags{} + + cmd := &cobra.Command{ + Use: "set ", + Short: "Persist a default Foundry project endpoint.", + Long: `Persist a default Foundry project endpoint in the azd global config +(~/.azd/config.json). Other commands will resolve this endpoint when no +azd environment or explicit flag is available.`, + Example: ` # Set the default project endpoint + azd ai project set https://my-project.services.ai.azure.com/api/projects/my-project`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + flags.endpoint = args[0] + flags.outputFmt = extCtx.OutputFormat + flags.noPrompt = extCtx.NoPrompt + + action := &ProjectSetAction{flags: flags} + return action.Run(cmd.Context()) + }, + } + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "table"}, + Default: "table", + }) + + return cmd +} + +// Run validates the endpoint and persists it to global config. +func (a *ProjectSetAction) Run(ctx context.Context) error { + normalized, pathWarning, err := validateProjectEndpoint(a.flags.endpoint) + if err != nil { + return err + } + + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + setAt, err := setProjectContext(ctx, azdClient, normalized) + if err != nil { + return err + } + + // Warn if inside an azd project (azd env takes precedence). + if a.flags.outputFmt != "json" && !a.flags.noPrompt { + if _, envErr := azdClient.Environment().GetCurrent( + ctx, &azdext.EmptyRequest{}, + ); envErr == nil { + fmt.Fprintln(os.Stderr, + "warning: an active azd environment is present; "+ + "its AZURE_AI_PROJECT_ENDPOINT takes precedence "+ + "over global context.") + } + } + + // Warn if the endpoint path does not look like /api/projects/. + if pathWarning && a.flags.outputFmt != "json" && !a.flags.noPrompt { + fmt.Fprintln(os.Stderr, + "warning: the endpoint path does not look like /api/projects/; "+ + "verify this is the correct Foundry project endpoint.") + } + + switch a.flags.outputFmt { + case "json": + result := projectSetResult{ + Endpoint: normalized, + Source: string(SourceGlobalConfig), + SourceDetail: "~/.azd/config.json", + SetAt: setAt, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) + default: + fmt.Printf("Project endpoint set: %s\n", normalized) + return nil + } +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_set_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_set_test.go new file mode 100644 index 00000000000..4f697156483 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_set_test.go @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProjectSetCommand_RequiresExactlyOneArg(t *testing.T) { + t.Parallel() + cmd := newProjectSetCommand(nil) + assert.Error(t, cmd.Args(cmd, []string{})) + assert.NoError(t, cmd.Args(cmd, []string{"https://x.services.ai.azure.com/api/projects/p"})) + assert.Error(t, cmd.Args(cmd, []string{"a", "b"})) +} + +func TestProjectSetCommand_DefaultOutputFormat(t *testing.T) { + t.Parallel() + cmd := newProjectSetCommand(nil) + assertOutputFlagOptions(t, cmd, "table", []string{"json", "table"}) +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go new file mode 100644 index 00000000000..66c10333a6c --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "text/tabwriter" + + "azure.ai.projects/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type projectShowFlags struct { + outputFmt string +} + +type projectShowResult struct { + Endpoint string `json:"endpoint"` + Source string `json:"source"` + SourceDetail string `json:"sourceDetail"` + AzdEnv string `json:"azdEnv"` + SetAt string `json:"setAt,omitempty"` +} + +// ProjectShowAction is the action for the `show` command. +type ProjectShowAction struct { + flags *projectShowFlags +} + +func newProjectShowCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + flags := &projectShowFlags{} + + cmd := &cobra.Command{ + Use: "show", + Short: "Display the currently resolved Foundry project endpoint.", + Long: `Display the currently resolved Foundry project endpoint and the source +that provided it. Useful for debugging which endpoint commands will use.`, + Example: ` # Show the resolved endpoint + azd ai project show`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + flags.outputFmt = extCtx.OutputFormat + + action := &ProjectShowAction{flags: flags} + return action.Run(cmd.Context()) + }, + } + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "table"}, + Default: "table", + }) + + return cmd +} + +// Run resolves and displays the current project endpoint and its source. +func (a *ProjectShowAction) Run(ctx context.Context) error { + result, err := resolveProjectEndpoint(ctx, resolveProjectEndpointOpts{}) + if err != nil { + // Re-wrap missing-endpoint errors to surface `project set` as the fix. + if localErr, ok := errors.AsType[*azdext.LocalError](err); ok && + localErr.Code == exterrors.CodeMissingProjectEndpoint { + return exterrors.Dependency( + exterrors.CodeMissingProjectEndpoint, + localErr.Message, + "run `azd ai project set ` to persist a default, or "+localErr.Suggestion, + ) + } + return err + } + + sourceDetail := humanSourceDetail(result.Source, result.AzdEnvName) + + switch a.flags.outputFmt { + case "json": + out := projectShowResult{ + Endpoint: result.Endpoint, + Source: string(result.Source), + SourceDetail: jsonSourceDetail(result.Source), + AzdEnv: result.AzdEnvName, + SetAt: result.SetAt, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(out) + default: + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintf(w, "Project endpoint:\t%s\n", result.Endpoint) + fmt.Fprintf(w, "Source:\t%s\n", sourceDetail) + if result.Source == SourceGlobalConfig && result.SetAt != "" { + fmt.Fprintf(w, "Set at:\t%s\n", result.SetAt) + } + return w.Flush() + } +} + +// humanSourceDetail returns a human-readable label for the endpoint source. +func humanSourceDetail(source EndpointSource, azdEnvName string) string { + switch source { + case SourceFlag: + return "--project-endpoint flag" + case SourceAzdEnv: + if azdEnvName != "" { + return fmt.Sprintf("azd env (%s)", azdEnvName) + } + return "azd env" + case SourceGlobalConfig: + return "global config (~/.azd/config.json)" + case SourceFoundryEnv: + return "FOUNDRY_PROJECT_ENDPOINT" + default: + return string(source) + } +} + +// jsonSourceDetail returns a stable, machine-readable source detail string for +// use in JSON output. These values are part of the public JSON contract and +// must not change without a deprecation. +func jsonSourceDetail(source EndpointSource) string { + switch source { + case SourceGlobalConfig: + return "~/.azd/config.json" + case SourceFoundryEnv: + return "FOUNDRY_PROJECT_ENDPOINT" + case SourceFlag: + return "--project-endpoint flag" + case SourceAzdEnv: + return "azd env" + default: + return string(source) + } +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show_test.go new file mode 100644 index 00000000000..54ed3fb3646 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show_test.go @@ -0,0 +1,92 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProjectShowCommand_AcceptsNoArgs(t *testing.T) { + t.Parallel() + cmd := newProjectShowCommand(nil) + assert.NoError(t, cmd.Args(cmd, []string{})) +} + +func TestProjectShowCommand_RejectsArgs(t *testing.T) { + t.Parallel() + cmd := newProjectShowCommand(nil) + assert.Error(t, cmd.Args(cmd, []string{"extra"})) +} + +func TestProjectShowCommand_DefaultOutputFormat(t *testing.T) { + t.Parallel() + cmd := newProjectShowCommand(nil) + assertOutputFlagOptions(t, cmd, "table", []string{"json", "table"}) +} + +func TestProjectShowCommand_HasNoProjectEndpointFlag(t *testing.T) { + t.Parallel() + cmd := newProjectShowCommand(nil) + assert.Nil(t, cmd.Flags().Lookup("project-endpoint"), + "--project-endpoint flag should not be registered on `project show`; "+ + "it adds no value over echoing back the user-provided URL") +} + +func TestHumanSourceDetail(t *testing.T) { + t.Parallel() + tests := []struct { + source EndpointSource + azdEnvName string + want string + }{ + {SourceFlag, "", "--project-endpoint flag"}, + {SourceAzdEnv, "dev", "azd env (dev)"}, + {SourceAzdEnv, "", "azd env"}, + {SourceGlobalConfig, "", "global config (~/.azd/config.json)"}, + {SourceFoundryEnv, "", "FOUNDRY_PROJECT_ENDPOINT"}, + } + for _, tt := range tests { + t.Run(string(tt.source)+"/"+tt.azdEnvName, func(t *testing.T) { + t.Parallel() + got := humanSourceDetail(tt.source, tt.azdEnvName) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestRootCommand_HasProjectSubcommands(t *testing.T) { + t.Parallel() + root := NewRootCommand() + names := make(map[string]bool, len(root.Commands())) + for _, sub := range root.Commands() { + names[sub.Name()] = true + } + assert.True(t, names["set"], "root should have `set` subcommand") + assert.True(t, names["unset"], "root should have `unset` subcommand") + assert.True(t, names["show"], "root should have `show` subcommand") +} + +func TestJSONSourceDetail(t *testing.T) { + t.Parallel() + // These values are part of the public JSON contract; verify they are stable. + tests := []struct { + source EndpointSource + want string + }{ + {SourceFlag, "--project-endpoint flag"}, + {SourceAzdEnv, "azd env"}, + {SourceGlobalConfig, "~/.azd/config.json"}, + {SourceFoundryEnv, "FOUNDRY_PROJECT_ENDPOINT"}, + } + for _, tt := range tests { + t.Run(string(tt.source), func(t *testing.T) { + t.Parallel() + got := jsonSourceDetail(tt.source) + assert.Equal(t, tt.want, got, + "jsonSourceDetail(%q) must return a stable machine-readable value", tt.source) + }) + } +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_unset.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_unset.go new file mode 100644 index 00000000000..692810b7a1c --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_unset.go @@ -0,0 +1,90 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type projectUnsetFlags struct { + outputFmt string +} + +type projectUnsetResult struct { + Cleared bool `json:"cleared"` + PreviousEndpoint string `json:"previousEndpoint"` +} + +// ProjectUnsetAction is the action for the `unset` command. +type ProjectUnsetAction struct { + flags *projectUnsetFlags +} + +func newProjectUnsetCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + flags := &projectUnsetFlags{} + + cmd := &cobra.Command{ + Use: "unset", + Short: "Clear the persisted Foundry project endpoint.", + Long: `Clear the persisted Foundry project endpoint from the azd global config +(~/.azd/config.json). This is idempotent — running it when no endpoint is set +is not an error.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + flags.outputFmt = extCtx.OutputFormat + + action := &ProjectUnsetAction{flags: flags} + return action.Run(cmd.Context()) + }, + } + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "table"}, + Default: "table", + }) + + return cmd +} + +// Run clears the persisted project endpoint from global config. +func (a *ProjectUnsetAction) Run(ctx context.Context) error { + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + previous, err := clearProjectContext(ctx, azdClient) + if err != nil { + return err + } + + cleared := previous != "" + + switch a.flags.outputFmt { + case "json": + result := projectUnsetResult{ + Cleared: cleared, + PreviousEndpoint: previous, + } + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(result) + default: + if !cleared { + fmt.Println("No active project endpoint to clear.") + } else { + fmt.Println("Project endpoint cleared.") + } + return nil + } +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_unset_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_unset_test.go new file mode 100644 index 00000000000..1e0a0c68603 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_unset_test.go @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestProjectUnsetCommand_AcceptsNoArgs(t *testing.T) { + t.Parallel() + cmd := newProjectUnsetCommand(nil) + assert.NoError(t, cmd.Args(cmd, []string{})) +} + +func TestProjectUnsetCommand_RejectsArgs(t *testing.T) { + t.Parallel() + cmd := newProjectUnsetCommand(nil) + assert.Error(t, cmd.Args(cmd, []string{"extra"})) +} + +func TestProjectUnsetCommand_DefaultOutputFormat(t *testing.T) { + t.Parallel() + cmd := newProjectUnsetCommand(nil) + assertOutputFlagOptions(t, cmd, "table", []string{"json", "table"}) +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go index c49db93b7e1..faa218b61c0 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go @@ -26,6 +26,9 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newContextCommand()) rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) rootCmd.AddCommand(newMetadataCommand(rootCmd)) + rootCmd.AddCommand(newProjectSetCommand(extCtx)) + rootCmd.AddCommand(newProjectUnsetCommand(extCtx)) + rootCmd.AddCommand(newProjectShowCommand(extCtx)) return rootCmd } diff --git a/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go new file mode 100644 index 00000000000..c0155586e72 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package exterrors + +// Error codes commonly used for validation errors. +// +// These are paired with [Validation] when user input or configuration values +// fail validation. +const ( + CodeInvalidParameter = "invalid_parameter" +) + +// Error codes commonly used for dependency errors. +// +// These are paired with [Dependency] when a required external value is missing. +const ( + CodeMissingProjectEndpoint = "missing_project_endpoint" +) diff --git a/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go b/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go new file mode 100644 index 00000000000..13ee718d1e3 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package exterrors provides structured error helpers for the azure.ai.projects extension. +// +// Use plain Go errors until the current code can confidently choose a final +// category, code, and suggestion. At that point, create a structured error +// with one of the helpers in this package. +// +// Once an error is structured, return it unchanged. Avoid wrapping a structured +// error with [fmt.Errorf] and %w for extra context: azd serializes the +// structured error's own message and metadata, not the outer wrapper text. +package exterrors + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// Validation returns a validation [azdext.LocalError] for user-input or +// configuration errors. +func Validation(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryValidation, + Suggestion: suggestion, + } +} + +// Dependency returns a dependency [azdext.LocalError] for missing resources or +// services. +func Dependency(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryDependency, + Suggestion: suggestion, + } +} From 45736ba2b260cacd665bbe604e827ff369f5f1d0 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 20 May 2026 15:07:12 +0800 Subject: [PATCH 2/9] feat(azure.ai.projects): address PR 8243 review feedback Code-correctness fixes (azure.ai.projects): - clearProjectContext now reads the previous endpoint best-effort so a malformed/older persisted blob cannot block 'unset' from clearing it. - project_show no longer concatenates the cascade-error suggestion onto its own; the structured noProjectEndpointError already includes the 'azd ai project set' guidance, so the resolver error is returned unchanged. - NewAzdClient() failures are now classified with exterrors.Dependency(CodeAzdClientFailed, ...) instead of fmt.Errorf, so azd host classification (status.FromError / errors.AsType[*LocalError]) is preserved. - resolveProjectEndpointOpts now carries an injectable ReadAzdHostedSources seam; the package-level readAzdHostedSourcesFunc global is gone, so resolver tests no longer mutate shared state. - The 'unset' JSON shape renames 'cleared' to 'previouslySet' and the no-op message now reads as confirmation, not failure. - exterrors package gets a doc note explaining the duplication with azure.ai.agents/internal/exterrors and pointing at future consolidation. Migration (azure.ai.agents): - Removed project, project_set, project_unset, project_show and their tests; removed the rootCmd.AddCommand(newProjectCommand(extCtx)) registration; pruned now-dead setProjectContext/clearProjectContext helpers. - CHANGELOG entry added directing users to 'azd ai project ...'. Backward-compat (azure.ai.projects): - The resolver now reads the legacy 'extensions.ai-agents.project.context' key as a one-time fallback when the new 'extensions.ai-projects.context' key has no value. Legacy values surface as SourceGlobalConfig with a FromLegacyAgentsConfig flag, and project_show prints a migration notice on stderr (plus emits the flag in JSON output). --- .../extensions/azure.ai.agents/CHANGELOG.md | 4 + .../azure.ai.agents/internal/cmd/project.go | 29 ---- .../internal/cmd/project_context_store.go | 55 ------ .../internal/cmd/project_set.go | 118 ------------- .../internal/cmd/project_set_test.go | 24 --- .../internal/cmd/project_show.go | 142 --------------- .../internal/cmd/project_show_test.go | 92 ---------- .../internal/cmd/project_unset.go | 90 ---------- .../internal/cmd/project_unset_test.go | 28 --- .../azure.ai.agents/internal/cmd/root.go | 1 - .../extensions/azure.ai.projects/CHANGELOG.md | 4 + .../extensions/azure.ai.projects/cspell.yaml | 4 +- .../internal/cmd/project_context_store.go | 42 ++++- .../internal/cmd/project_endpoint.go | 7 + .../internal/cmd/project_resolver.go | 49 +++++- .../internal/cmd/project_resolver_test.go | 162 +++++++++++------- .../internal/cmd/project_set.go | 10 +- .../internal/cmd/project_show.go | 45 +++-- .../internal/cmd/project_unset.go | 24 ++- .../internal/exterrors/codes.go | 1 + .../internal/exterrors/errors.go | 7 + 21 files changed, 249 insertions(+), 689 deletions(-) delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project_set.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project_set_test.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project_show.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project_show_test.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset.go delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset_test.go diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index d3f82e0d74a..d6fedf53e3a 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,5 +1,9 @@ # Release History +## Unreleased + +- [[#8243]](https://github.com/Azure/azure-dev/pull/8243) **Removed** `azd ai agent project set|unset|show` commands. The functionality moved to the `azure.ai.projects` extension as `azd ai project set|unset|show`. Endpoints previously persisted by `azd ai agent project set` continue to resolve in the new extension on first read, and the new `azd ai project show` surfaces a one-time notice prompting users to re-run `azd ai project set` to migrate. + ## 0.1.32-preview (2026-05-18) - [[#8222]](https://github.com/Azure/azure-dev/pull/8222) Add post-init validation to check .NET runtime compatibility with project TargetFramework and show guidance when mismatched. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project.go deleted file mode 100644 index d117446f75d..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/spf13/cobra" -) - -func newProjectCommand(extCtx *azdext.ExtensionContext) *cobra.Command { - extCtx = ensureExtensionContext(extCtx) - - cmd := &cobra.Command{ - Use: "project [options]", - Short: "Manage the default Microsoft Foundry project endpoint.", - Long: `Manage the default Microsoft Foundry project endpoint. - -These commands persist a workspace-level project endpoint in the azd global -config (~/.azd/config.json) so that other agent commands can resolve it -without requiring an azd environment or explicit flags.`, - } - - cmd.AddCommand(newProjectSetCommand(extCtx)) - cmd.AddCommand(newProjectUnsetCommand(extCtx)) - cmd.AddCommand(newProjectShowCommand(extCtx)) - - return cmd -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_context_store.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_context_store.go index 82d46b5a406..ffb963ad7e0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_context_store.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_context_store.go @@ -6,7 +6,6 @@ package cmd import ( "context" "fmt" - "time" "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) @@ -45,57 +44,3 @@ func getProjectContext( return state, true, nil } - -// setProjectContext persists a validated project endpoint to global config. -// The caller is responsible for validating the endpoint before calling this function. -// Returns the setAt timestamp that was written to config. -func setProjectContext( - ctx context.Context, - azdClient *azdext.AzdClient, - endpoint string, -) (setAt string, err error) { - ch, chErr := azdext.NewConfigHelper(azdClient) - if chErr != nil { - return "", fmt.Errorf("setProjectContext: %w", chErr) - } - - state := projectContextState{ - Endpoint: endpoint, - SetAt: time.Now().UTC().Format(time.RFC3339), - } - - if err := ch.SetUserJSON(ctx, projectContextConfigPath, state); err != nil { - return "", fmt.Errorf("setProjectContext: failed to write config: %w", err) - } - - return state.SetAt, nil -} - -// clearProjectContext removes the context subtree from global config. -// Returns the previously stored endpoint (empty if none was set). -// The operation is idempotent — calling it when no context is set is not an error. -func clearProjectContext( - ctx context.Context, - azdClient *azdext.AzdClient, -) (previousEndpoint string, err error) { - // Read existing state first so we can return the previous endpoint. - state, found, err := getProjectContext(ctx, azdClient) - if err != nil { - return "", err - } - - if found { - previousEndpoint = state.Endpoint - } - - ch, chErr := azdext.NewConfigHelper(azdClient) - if chErr != nil { - return "", fmt.Errorf("clearProjectContext: %w", chErr) - } - - if err := ch.UnsetUser(ctx, projectContextConfigPath); err != nil { - return "", fmt.Errorf("clearProjectContext: failed to clear config: %w", err) - } - - return previousEndpoint, nil -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_set.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_set.go deleted file mode 100644 index 3d4f73ae742..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_set.go +++ /dev/null @@ -1,118 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "context" - "encoding/json" - "fmt" - "os" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/spf13/cobra" -) - -type projectSetFlags struct { - endpoint string - outputFmt string - noPrompt bool -} - -type projectSetResult struct { - Endpoint string `json:"endpoint"` - Source string `json:"source"` - SourceDetail string `json:"sourceDetail"` - SetAt string `json:"setAt"` -} - -// ProjectSetAction is the action for the `project set` command. -type ProjectSetAction struct { - flags *projectSetFlags -} - -func newProjectSetCommand(extCtx *azdext.ExtensionContext) *cobra.Command { - extCtx = ensureExtensionContext(extCtx) - flags := &projectSetFlags{} - - cmd := &cobra.Command{ - Use: "set ", - Short: "Persist a default Foundry project endpoint.", - Long: `Persist a default Foundry project endpoint in the azd global config -(~/.azd/config.json). Other agent commands will resolve this endpoint when no -azd environment or explicit flag is available.`, - Example: ` # Set the default project endpoint - azd ai agent project set https://my-project.services.ai.azure.com/api/projects/my-project`, - Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - flags.endpoint = args[0] - flags.outputFmt = extCtx.OutputFormat - flags.noPrompt = extCtx.NoPrompt - - action := &ProjectSetAction{flags: flags} - return action.Run(cmd.Context()) - }, - } - - azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ - Name: "output", - AllowedValues: []string{"json", "table"}, - Default: "table", - }) - - return cmd -} - -// Run validates the endpoint and persists it to global config. -func (a *ProjectSetAction) Run(ctx context.Context) error { - normalized, pathWarning, err := validateProjectEndpoint(a.flags.endpoint) - if err != nil { - return err - } - - azdClient, err := azdext.NewAzdClient() - if err != nil { - return fmt.Errorf("failed to create azd client: %w", err) - } - defer azdClient.Close() - - setAt, err := setProjectContext(ctx, azdClient, normalized) - if err != nil { - return err - } - - // Warn if inside an azd project (azd env takes precedence). - if a.flags.outputFmt != "json" && !a.flags.noPrompt { - if _, envErr := azdClient.Environment().GetCurrent( - ctx, &azdext.EmptyRequest{}, - ); envErr == nil { - fmt.Fprintln(os.Stderr, - "warning: an active azd environment is present; "+ - "its AZURE_AI_PROJECT_ENDPOINT takes precedence "+ - "over global context.") - } - } - - // Warn if the endpoint path does not look like /api/projects/. - if pathWarning && a.flags.outputFmt != "json" && !a.flags.noPrompt { - fmt.Fprintln(os.Stderr, - "warning: the endpoint path does not look like /api/projects/; "+ - "verify this is the correct Foundry project endpoint.") - } - - switch a.flags.outputFmt { - case "json": - result := projectSetResult{ - Endpoint: normalized, - Source: string(SourceGlobalConfig), - SourceDetail: "~/.azd/config.json", - SetAt: setAt, - } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(result) - default: - fmt.Printf("Project endpoint set: %s\n", normalized) - return nil - } -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_set_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_set_test.go deleted file mode 100644 index 4f697156483..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_set_test.go +++ /dev/null @@ -1,24 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestProjectSetCommand_RequiresExactlyOneArg(t *testing.T) { - t.Parallel() - cmd := newProjectSetCommand(nil) - assert.Error(t, cmd.Args(cmd, []string{})) - assert.NoError(t, cmd.Args(cmd, []string{"https://x.services.ai.azure.com/api/projects/p"})) - assert.Error(t, cmd.Args(cmd, []string{"a", "b"})) -} - -func TestProjectSetCommand_DefaultOutputFormat(t *testing.T) { - t.Parallel() - cmd := newProjectSetCommand(nil) - assertOutputFlagOptions(t, cmd, "table", []string{"json", "table"}) -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show.go deleted file mode 100644 index c97ca71cd41..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show.go +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "os" - "text/tabwriter" - - "azureaiagent/internal/exterrors" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/spf13/cobra" -) - -type projectShowFlags struct { - outputFmt string -} - -type projectShowResult struct { - Endpoint string `json:"endpoint"` - Source string `json:"source"` - SourceDetail string `json:"sourceDetail"` - AzdEnv string `json:"azdEnv"` - SetAt string `json:"setAt,omitempty"` -} - -// ProjectShowAction is the action for the `project show` command. -type ProjectShowAction struct { - flags *projectShowFlags -} - -func newProjectShowCommand(extCtx *azdext.ExtensionContext) *cobra.Command { - extCtx = ensureExtensionContext(extCtx) - flags := &projectShowFlags{} - - cmd := &cobra.Command{ - Use: "show", - Short: "Display the currently resolved Foundry project endpoint.", - Long: `Display the currently resolved Foundry project endpoint and the source -that provided it. Useful for debugging which endpoint agent commands will use.`, - Example: ` # Show the resolved endpoint - azd ai agent project show`, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - flags.outputFmt = extCtx.OutputFormat - - action := &ProjectShowAction{flags: flags} - return action.Run(cmd.Context()) - }, - } - - azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ - Name: "output", - AllowedValues: []string{"json", "table"}, - Default: "table", - }) - - return cmd -} - -// Run resolves and displays the current project endpoint and its source. -func (a *ProjectShowAction) Run(ctx context.Context) error { - result, err := resolveProjectEndpoint(ctx, resolveProjectEndpointOpts{}) - if err != nil { - // Re-wrap missing-endpoint errors to surface `project set` as the fix. - if localErr, ok := errors.AsType[*azdext.LocalError](err); ok && - localErr.Code == exterrors.CodeMissingProjectEndpoint { - return exterrors.Dependency( - exterrors.CodeMissingProjectEndpoint, - localErr.Message, - "run `azd ai agent project set ` to persist a default, or "+localErr.Suggestion, - ) - } - return err - } - - sourceDetail := humanSourceDetail(result.Source, result.AzdEnvName) - - switch a.flags.outputFmt { - case "json": - out := projectShowResult{ - Endpoint: result.Endpoint, - Source: string(result.Source), - SourceDetail: jsonSourceDetail(result.Source), - AzdEnv: result.AzdEnvName, - SetAt: result.SetAt, - } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(out) - default: - w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) - fmt.Fprintf(w, "Project endpoint:\t%s\n", result.Endpoint) - fmt.Fprintf(w, "Source:\t%s\n", sourceDetail) - if result.Source == SourceGlobalConfig && result.SetAt != "" { - fmt.Fprintf(w, "Set at:\t%s\n", result.SetAt) - } - return w.Flush() - } -} - -// humanSourceDetail returns a human-readable label for the endpoint source. -func humanSourceDetail(source EndpointSource, azdEnvName string) string { - switch source { - case SourceFlag: - return "--project-endpoint flag" - case SourceAzdEnv: - if azdEnvName != "" { - return fmt.Sprintf("azd env (%s)", azdEnvName) - } - return "azd env" - case SourceGlobalConfig: - return "global config (~/.azd/config.json)" - case SourceFoundryEnv: - return "FOUNDRY_PROJECT_ENDPOINT" - default: - return string(source) - } -} - -// jsonSourceDetail returns a stable, machine-readable source detail string for -// use in JSON output. These values are part of the public JSON contract and -// must not change without a deprecation. -func jsonSourceDetail(source EndpointSource) string { - switch source { - case SourceGlobalConfig: - return "~/.azd/config.json" - case SourceFoundryEnv: - return "FOUNDRY_PROJECT_ENDPOINT" - case SourceFlag: - return "--project-endpoint flag" - case SourceAzdEnv: - return "azd env" - default: - return string(source) - } -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show_test.go deleted file mode 100644 index 67018e7c46c..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show_test.go +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestProjectShowCommand_AcceptsNoArgs(t *testing.T) { - t.Parallel() - cmd := newProjectShowCommand(nil) - assert.NoError(t, cmd.Args(cmd, []string{})) -} - -func TestProjectShowCommand_RejectsArgs(t *testing.T) { - t.Parallel() - cmd := newProjectShowCommand(nil) - assert.Error(t, cmd.Args(cmd, []string{"extra"})) -} - -func TestProjectShowCommand_DefaultOutputFormat(t *testing.T) { - t.Parallel() - cmd := newProjectShowCommand(nil) - assertOutputFlagOptions(t, cmd, "table", []string{"json", "table"}) -} - -func TestProjectShowCommand_HasNoProjectEndpointFlag(t *testing.T) { - t.Parallel() - cmd := newProjectShowCommand(nil) - assert.Nil(t, cmd.Flags().Lookup("project-endpoint"), - "--project-endpoint flag should not be registered on `project show`; "+ - "it adds no value over echoing back the user-provided URL") -} - -func TestHumanSourceDetail(t *testing.T) { - t.Parallel() - tests := []struct { - source EndpointSource - azdEnvName string - want string - }{ - {SourceFlag, "", "--project-endpoint flag"}, - {SourceAzdEnv, "dev", "azd env (dev)"}, - {SourceAzdEnv, "", "azd env"}, - {SourceGlobalConfig, "", "global config (~/.azd/config.json)"}, - {SourceFoundryEnv, "", "FOUNDRY_PROJECT_ENDPOINT"}, - } - for _, tt := range tests { - t.Run(string(tt.source)+"/"+tt.azdEnvName, func(t *testing.T) { - t.Parallel() - got := humanSourceDetail(tt.source, tt.azdEnvName) - assert.Equal(t, tt.want, got) - }) - } -} - -func TestProjectCommand_HasSubcommands(t *testing.T) { - t.Parallel() - cmd := newProjectCommand(nil) - names := make([]string, 0, len(cmd.Commands())) - for _, sub := range cmd.Commands() { - names = append(names, sub.Name()) - } - assert.Contains(t, names, "set") - assert.Contains(t, names, "unset") - assert.Contains(t, names, "show") -} - -func TestJSONSourceDetail(t *testing.T) { - t.Parallel() - // These values are part of the public JSON contract; verify they are stable. - tests := []struct { - source EndpointSource - want string - }{ - {SourceFlag, "--project-endpoint flag"}, - {SourceAzdEnv, "azd env"}, - {SourceGlobalConfig, "~/.azd/config.json"}, - {SourceFoundryEnv, "FOUNDRY_PROJECT_ENDPOINT"}, - } - for _, tt := range tests { - t.Run(string(tt.source), func(t *testing.T) { - t.Parallel() - got := jsonSourceDetail(tt.source) - assert.Equal(t, tt.want, got, - "jsonSourceDetail(%q) must return a stable machine-readable value", tt.source) - }) - } -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset.go deleted file mode 100644 index 50b6e9bfffd..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset.go +++ /dev/null @@ -1,90 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "context" - "encoding/json" - "fmt" - "os" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/spf13/cobra" -) - -type projectUnsetFlags struct { - outputFmt string -} - -type projectUnsetResult struct { - Cleared bool `json:"cleared"` - PreviousEndpoint string `json:"previousEndpoint"` -} - -// ProjectUnsetAction is the action for the `project unset` command. -type ProjectUnsetAction struct { - flags *projectUnsetFlags -} - -func newProjectUnsetCommand(extCtx *azdext.ExtensionContext) *cobra.Command { - extCtx = ensureExtensionContext(extCtx) - flags := &projectUnsetFlags{} - - cmd := &cobra.Command{ - Use: "unset", - Short: "Clear the persisted Foundry project endpoint.", - Long: `Clear the persisted Foundry project endpoint from the azd global config -(~/.azd/config.json). This is idempotent — running it when no endpoint is set -is not an error.`, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, _ []string) error { - flags.outputFmt = extCtx.OutputFormat - - action := &ProjectUnsetAction{flags: flags} - return action.Run(cmd.Context()) - }, - } - - azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ - Name: "output", - AllowedValues: []string{"json", "table"}, - Default: "table", - }) - - return cmd -} - -// Run clears the persisted project endpoint from global config. -func (a *ProjectUnsetAction) Run(ctx context.Context) error { - azdClient, err := azdext.NewAzdClient() - if err != nil { - return fmt.Errorf("failed to create azd client: %w", err) - } - defer azdClient.Close() - - previous, err := clearProjectContext(ctx, azdClient) - if err != nil { - return err - } - - cleared := previous != "" - - switch a.flags.outputFmt { - case "json": - result := projectUnsetResult{ - Cleared: cleared, - PreviousEndpoint: previous, - } - enc := json.NewEncoder(os.Stdout) - enc.SetIndent("", " ") - return enc.Encode(result) - default: - if !cleared { - fmt.Println("No active project endpoint to clear.") - } else { - fmt.Println("Project endpoint cleared.") - } - return nil - } -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset_test.go deleted file mode 100644 index 1e0a0c68603..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset_test.go +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "testing" - - "github.com/stretchr/testify/assert" -) - -func TestProjectUnsetCommand_AcceptsNoArgs(t *testing.T) { - t.Parallel() - cmd := newProjectUnsetCommand(nil) - assert.NoError(t, cmd.Args(cmd, []string{})) -} - -func TestProjectUnsetCommand_RejectsArgs(t *testing.T) { - t.Parallel() - cmd := newProjectUnsetCommand(nil) - assert.Error(t, cmd.Args(cmd, []string{"extra"})) -} - -func TestProjectUnsetCommand_DefaultOutputFormat(t *testing.T) { - t.Parallel() - cmd := newProjectUnsetCommand(nil) - assertOutputFlagOptions(t, cmd, "table", []string{"json", "table"}) -} 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 64e45361a18..23c71da3720 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go @@ -62,7 +62,6 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newMonitorCommand(extCtx)) rootCmd.AddCommand(newFilesCommand(extCtx)) rootCmd.AddCommand(newSessionCommand(extCtx)) - rootCmd.AddCommand(newProjectCommand(extCtx)) // Connection commands — in separate package for easy lift-and-shift later. // When the azd core namespace change lands, move this AddCommand call diff --git a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md index 3a52e0f1122..da9ba3cefc5 100644 --- a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md @@ -1,3 +1,7 @@ # Release History +## Unreleased + +- [[#8243]](https://github.com/Azure/azure-dev/pull/8243) Add `azd ai project set|unset|show` commands for persisting a default Microsoft Foundry project endpoint. Migrated from `azd ai agent project ...`. Endpoints set by the removed `azd ai agent project set` command are still resolved on first read (with a one-time migration notice surfaced by `azd ai project show`). + ## 0.0.1-preview - Initial Version \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.projects/cspell.yaml b/cli/azd/extensions/azure.ai.projects/cspell.yaml index 258d305a22d..d0510f7e9e3 100644 --- a/cli/azd/extensions/azure.ai.projects/cspell.yaml +++ b/cli/azd/extensions/azure.ai.projects/cspell.yaml @@ -1,2 +1,4 @@ import: ../../.vscode/cspell.yaml -words: [] +words: + - exterrors + - idempotently diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go index e81031712ec..48838d9d8e5 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go @@ -46,6 +46,32 @@ func getProjectContext( return state, true, nil } +// getLegacyAgentsProjectContext reads the persisted project context written by +// the (now-removed) `azd ai agent project set` command in the azure.ai.agents +// extension. It is used as a one-time read-only fallback to keep existing +// users working immediately after they upgrade. +// +// Returns (state, true, nil) when present, (zero, false, nil) when absent. +// Errors reading the legacy key are best-effort and are not returned: a +// malformed or unavailable legacy value should never break the new resolver. +func getLegacyAgentsProjectContext( + ctx context.Context, + azdClient *azdext.AzdClient, +) (projectContextState, bool) { + ch, err := azdext.NewConfigHelper(azdClient) + if err != nil { + return projectContextState{}, false + } + + var state projectContextState + found, err := ch.GetUserJSON(ctx, legacyAgentsContextPath, &state) + if err != nil || !found || state.Endpoint == "" { + return projectContextState{}, false + } + + return state, true +} + // setProjectContext persists a validated project endpoint to global config. // The caller is responsible for validating the endpoint before calling this function. // Returns the setAt timestamp that was written to config. @@ -72,19 +98,19 @@ func setProjectContext( } // clearProjectContext removes the context subtree from global config. -// Returns the previously stored endpoint (empty if none was set). +// Returns the previously stored endpoint (empty if none was set, or if the +// previous value could not be decoded). // The operation is idempotent — calling it when no context is set is not an error. +// +// The previous-endpoint read is intentionally best-effort: if the persisted blob +// is malformed (e.g. written by an older or buggy version), `unset` must still be +// able to clear it. Surfacing the decode error here would block users from +// recovering, so we only return errors from the actual `UnsetUser` write. func clearProjectContext( ctx context.Context, azdClient *azdext.AzdClient, ) (previousEndpoint string, err error) { - // Read existing state first so we can return the previous endpoint. - state, found, err := getProjectContext(ctx, azdClient) - if err != nil { - return "", err - } - - if found { + if state, found, readErr := getProjectContext(ctx, azdClient); readErr == nil && found { previousEndpoint = state.Endpoint } diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint.go index b586f760cb3..bf4100e3695 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint.go @@ -15,6 +15,13 @@ import ( // persisted by this extension (stored in ~/.azd/config.json). const configPathPrefix = "extensions.ai-projects" +// legacyAgentsContextPath is the UserConfig path where the (now-removed) +// `azd ai agent project set` command persisted the project endpoint in the +// `azure.ai.agents` extension. The resolver reads it as a one-time fallback +// when no value exists at the new path, so users who set an endpoint via +// the old command keep working after upgrading. +const legacyAgentsContextPath = "extensions.ai-agents.project.context" + // EndpointSource identifies where the resolved project endpoint came from. type EndpointSource string diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go index c89a5692805..645e5962252 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go @@ -18,6 +18,13 @@ type resolveProjectEndpointOpts struct { // FlagValue is the value of an explicit endpoint flag (level 1). // Empty means the flag was not provided. FlagValue string + + // ReadAzdHostedSources is a test-injected override for the azd-hosted + // lookup (levels 2 + 3). Production callers leave this nil; the resolver + // then falls back to the real [readAzdHostedSources]. Using an + // instance-scoped seam instead of a package-level var keeps the resolver + // safe to call from parallel tests. + ReadAzdHostedSources func(context.Context) (azdHostedSources, error) } // resolvedEndpoint holds the result of resolveProjectEndpoint. @@ -26,12 +33,17 @@ type resolvedEndpoint struct { Source EndpointSource AzdEnvName string SetAt string // RFC3339 timestamp, only meaningful when Source == SourceGlobalConfig + // FromLegacyAgentsConfig is true when the endpoint was sourced from the + // legacy `extensions.ai-agents.project.context` key written by the removed + // `azd ai agent project set` command. Callers should surface a one-time + // notice so users re-run `azd ai project set` to migrate. + FromLegacyAgentsConfig bool } // azdHostedSources holds the values that the resolver reads from azd-managed // sources (the active azd environment and ~/.azd/config.json). It is returned // as a single struct so that tests can stub the whole lookup via -// readAzdHostedSourcesFunc. +// resolveProjectEndpointOpts.ReadAzdHostedSources. type azdHostedSources struct { // EnvValue is the AZURE_AI_PROJECT_ENDPOINT value from the active azd // env, or "" if not set / no active env / no azd client available. @@ -42,12 +54,13 @@ type azdHostedSources struct { CfgState projectContextState // CfgFound indicates whether a non-empty endpoint was found in global config. CfgFound bool + // CfgFromLegacyAgents reports whether CfgState was sourced from the + // legacy `extensions.ai-agents.project.context` key (left behind by the + // removed `azd ai agent project set` command). Callers can use this to + // surface a one-time migration notice. Only meaningful when CfgFound is true. + CfgFromLegacyAgents bool } -// readAzdHostedSourcesFunc is a package-level seam so tests can stub the -// daemon-backed lookup without spinning up a real azd gRPC server. -var readAzdHostedSourcesFunc = readAzdHostedSources - // readAzdHostedSources dials the azd daemon (if reachable) and reads both // the active env's AZURE_AI_PROJECT_ENDPOINT and the global-config project // context in a single client lifetime. Errors talking to the daemon are @@ -90,6 +103,19 @@ func readAzdHostedSources(ctx context.Context) (azdHostedSources, error) { out.CfgFound = found } + // One-time legacy fallback: if the new key has no value, try the path + // written by the removed `azd ai agent project set` command. Read errors + // are swallowed (best-effort) so a malformed legacy blob never blocks the + // new resolver — users can still resolve via FOUNDRY_PROJECT_ENDPOINT or + // re-run `azd ai project set`. + if !out.CfgFound { + if legacyState, legacyFound := getLegacyAgentsProjectContext(ctx, azdClient); legacyFound { + out.CfgState = legacyState + out.CfgFound = true + out.CfgFromLegacyAgents = true + } + } + return out, nil } @@ -133,7 +159,11 @@ func resolveProjectEndpoint( } // Levels 2 + 3: azd-hosted sources (active env, then global config). - sources, err := readAzdHostedSourcesFunc(ctx) + readSources := opts.ReadAzdHostedSources + if readSources == nil { + readSources = readAzdHostedSources + } + sources, err := readSources(ctx) if err != nil { return nil, err } @@ -158,9 +188,10 @@ func resolveProjectEndpoint( return nil, err } return &resolvedEndpoint{ - Endpoint: normalized, - Source: SourceGlobalConfig, - SetAt: sources.CfgState.SetAt, + Endpoint: normalized, + Source: SourceGlobalConfig, + SetAt: sources.CfgState.SetAt, + FromLegacyAgentsConfig: sources.CfgFromLegacyAgents, }, nil } diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver_test.go index ebc92d90d17..b4e8a78470d 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver_test.go @@ -15,41 +15,43 @@ import ( "github.com/stretchr/testify/require" ) -// stubAzdHostedSources replaces readAzdHostedSourcesFunc for the duration of -// the test with a function that returns the given sources/err. -func stubAzdHostedSources(t *testing.T, sources azdHostedSources, err error) { - t.Helper() - orig := readAzdHostedSourcesFunc - readAzdHostedSourcesFunc = func(context.Context) (azdHostedSources, error) { +// stubReadAzdHostedSources returns a function suitable for the +// resolveProjectEndpointOpts.ReadAzdHostedSources seam. Each test gets its own +// captured closure, so parallel tests do not race on a shared global. +func stubReadAzdHostedSources( + sources azdHostedSources, + err error, +) func(context.Context) (azdHostedSources, error) { + return func(context.Context) (azdHostedSources, error) { return sources, err } - t.Cleanup(func() { readAzdHostedSourcesFunc = orig }) } -// isolateFromAzdDaemon makes the test independent of any azd daemon that -// might be reachable on the developer machine via AZD_SERVER. It does two -// things: -// - Clears AZD_SERVER so azdext.NewAzdClient() cannot connect. -// - Stubs readAzdHostedSourcesFunc to return no hosted sources. +// isolateFromAzdDaemon returns resolver opts that are isolated from any azd +// daemon the developer machine might have running: +// - Clears AZD_SERVER on the test's env so any real-client paths cannot connect. +// - Returns opts whose ReadAzdHostedSources reports no hosted sources. // // Together this ensures the resolver under test only sees the flag and the // FOUNDRY_PROJECT_ENDPOINT host env var. -func isolateFromAzdDaemon(t *testing.T) { +func isolateFromAzdDaemon(t *testing.T) resolveProjectEndpointOpts { t.Helper() t.Setenv("AZD_SERVER", "") - stubAzdHostedSources(t, azdHostedSources{}, nil) + return resolveProjectEndpointOpts{ + ReadAzdHostedSources: stubReadAzdHostedSources(azdHostedSources{}, nil), + } } func TestResolveProjectEndpoint_FlagWins(t *testing.T) { // Even with FOUNDRY_PROJECT_ENDPOINT and azd-hosted sources set, the flag should win. t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://env.services.ai.azure.com/api/projects/env-proj") - stubAzdHostedSources(t, azdHostedSources{ - EnvValue: "https://azdenv.services.ai.azure.com/api/projects/p", - EnvName: "dev", - }, nil) result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ FlagValue: "https://flag.services.ai.azure.com/api/projects/flag-proj", + ReadAzdHostedSources: stubReadAzdHostedSources(azdHostedSources{ + EnvValue: "https://azdenv.services.ai.azure.com/api/projects/p", + EnvName: "dev", + }, nil), }) require.NoError(t, err) assert.Equal(t, "https://flag.services.ai.azure.com/api/projects/flag-proj", result.Endpoint) @@ -60,17 +62,18 @@ func TestResolveProjectEndpoint_AzdEnvResolves(t *testing.T) { // Level 2: AZURE_AI_PROJECT_ENDPOINT from the active azd env wins over // global config and FOUNDRY_PROJECT_ENDPOINT. t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") - stubAzdHostedSources(t, azdHostedSources{ - EnvValue: " HTTPS://Azdenv.Services.AI.Azure.com/api/projects/p/ ", - EnvName: "dev", - CfgState: projectContextState{ - Endpoint: "https://cfg.services.ai.azure.com/api/projects/p", - SetAt: "2025-01-01T00:00:00Z", - }, - CfgFound: true, - }, nil) - - result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + + result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + ReadAzdHostedSources: stubReadAzdHostedSources(azdHostedSources{ + EnvValue: " HTTPS://Azdenv.Services.AI.Azure.com/api/projects/p/ ", + EnvName: "dev", + CfgState: projectContextState{ + Endpoint: "https://cfg.services.ai.azure.com/api/projects/p", + SetAt: "2025-01-01T00:00:00Z", + }, + CfgFound: true, + }, nil), + }) require.NoError(t, err) assert.Equal(t, "https://azdenv.services.ai.azure.com/api/projects/p", result.Endpoint) assert.Equal(t, SourceAzdEnv, result.Source) @@ -81,12 +84,13 @@ func TestResolveProjectEndpoint_AzdEnvInvalidRejected(t *testing.T) { // Level 2 invalid values are hard errors (no silent fallback to lower levels). // FOUNDRY_PROJECT_ENDPOINT is set, but resolver must NOT fall through to it. t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") - stubAzdHostedSources(t, azdHostedSources{ - EnvValue: "http://not-https.services.ai.azure.com/api/projects/p", - EnvName: "dev", - }, nil) - _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + ReadAzdHostedSources: stubReadAzdHostedSources(azdHostedSources{ + EnvValue: "http://not-https.services.ai.azure.com/api/projects/p", + EnvName: "dev", + }, nil), + }) require.Error(t, err) var localErr *azdext.LocalError @@ -97,15 +101,16 @@ func TestResolveProjectEndpoint_AzdEnvInvalidRejected(t *testing.T) { func TestResolveProjectEndpoint_GlobalConfigResolves(t *testing.T) { // Level 3: global config wins over FOUNDRY_PROJECT_ENDPOINT when no azd env value is set. t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") - stubAzdHostedSources(t, azdHostedSources{ - CfgState: projectContextState{ - Endpoint: " HTTPS://Cfg.Services.AI.Azure.com/api/projects/p/ ", - SetAt: "2025-01-02T03:04:05Z", - }, - CfgFound: true, - }, nil) - - result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + + result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + ReadAzdHostedSources: stubReadAzdHostedSources(azdHostedSources{ + CfgState: projectContextState{ + Endpoint: " HTTPS://Cfg.Services.AI.Azure.com/api/projects/p/ ", + SetAt: "2025-01-02T03:04:05Z", + }, + CfgFound: true, + }, nil), + }) require.NoError(t, err) assert.Equal(t, "https://cfg.services.ai.azure.com/api/projects/p", result.Endpoint) assert.Equal(t, SourceGlobalConfig, result.Source) @@ -115,15 +120,16 @@ func TestResolveProjectEndpoint_GlobalConfigResolves(t *testing.T) { func TestResolveProjectEndpoint_GlobalConfigInvalidRejected(t *testing.T) { // Level 3 invalid values are hard errors (no silent fallback to level 4). t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") - stubAzdHostedSources(t, azdHostedSources{ - CfgState: projectContextState{ - Endpoint: "http://not-https.services.ai.azure.com/api/projects/p", - SetAt: "2025-01-02T03:04:05Z", - }, - CfgFound: true, - }, nil) - - _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + + _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + ReadAzdHostedSources: stubReadAzdHostedSources(azdHostedSources{ + CfgState: projectContextState{ + Endpoint: "http://not-https.services.ai.azure.com/api/projects/p", + SetAt: "2025-01-02T03:04:05Z", + }, + CfgFound: true, + }, nil), + }) require.Error(t, err) var localErr *azdext.LocalError @@ -136,28 +142,29 @@ func TestResolveProjectEndpoint_HostedSourcesErrorPropagates(t *testing.T) { // failure) must be surfaced and must not silently fall through to level 4. t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") sentinel := errors.New("boom") - stubAzdHostedSources(t, azdHostedSources{}, sentinel) - _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + ReadAzdHostedSources: stubReadAzdHostedSources(azdHostedSources{}, sentinel), + }) require.ErrorIs(t, err, sentinel) } func TestResolveProjectEndpoint_FoundryEnvFallback(t *testing.T) { // No flag, no azd-hosted sources → falls back to FOUNDRY_PROJECT_ENDPOINT. - isolateFromAzdDaemon(t) + opts := isolateFromAzdDaemon(t) t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://env.services.ai.azure.com/api/projects/env-proj") - result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + result, err := resolveProjectEndpoint(t.Context(), opts) require.NoError(t, err) assert.Equal(t, "https://env.services.ai.azure.com/api/projects/env-proj", result.Endpoint) assert.Equal(t, SourceFoundryEnv, result.Source) } func TestResolveProjectEndpoint_NothingResolvable(t *testing.T) { - isolateFromAzdDaemon(t) + opts := isolateFromAzdDaemon(t) t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "") - _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + _, err := resolveProjectEndpoint(t.Context(), opts) require.Error(t, err) var localErr *azdext.LocalError @@ -166,10 +173,9 @@ func TestResolveProjectEndpoint_NothingResolvable(t *testing.T) { } func TestResolveProjectEndpoint_InvalidFlagRejected(t *testing.T) { - isolateFromAzdDaemon(t) - _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ - FlagValue: "http://not-https.services.ai.azure.com/api/projects/p", - }) + opts := isolateFromAzdDaemon(t) + opts.FlagValue = "http://not-https.services.ai.azure.com/api/projects/p" + _, err := resolveProjectEndpoint(t.Context(), opts) require.Error(t, err) var localErr *azdext.LocalError @@ -178,10 +184,10 @@ func TestResolveProjectEndpoint_InvalidFlagRejected(t *testing.T) { } func TestResolveProjectEndpoint_InvalidFoundryEnvRejected(t *testing.T) { - isolateFromAzdDaemon(t) + opts := isolateFromAzdDaemon(t) t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "http://bad.services.ai.azure.com/api/projects/p") - _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + _, err := resolveProjectEndpoint(t.Context(), opts) require.Error(t, err) var localErr *azdext.LocalError @@ -190,11 +196,35 @@ func TestResolveProjectEndpoint_InvalidFoundryEnvRejected(t *testing.T) { } func TestResolveProjectEndpoint_FoundryEnvNormalized(t *testing.T) { - isolateFromAzdDaemon(t) + opts := isolateFromAzdDaemon(t) t.Setenv("FOUNDRY_PROJECT_ENDPOINT", " https://X.SERVICES.AI.AZURE.COM/api/projects/p/ ") - result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) + result, err := resolveProjectEndpoint(t.Context(), opts) require.NoError(t, err) assert.Equal(t, "https://x.services.ai.azure.com/api/projects/p", result.Endpoint) assert.Equal(t, SourceFoundryEnv, result.Source) } + +func TestResolveProjectEndpoint_LegacyAgentsConfigResolves(t *testing.T) { + // Level 3 with the new key absent, but the legacy + // `extensions.ai-agents.project.context` key present. The resolver should + // surface the legacy value as SourceGlobalConfig and flag the result so + // callers can prompt the user to migrate. + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") + + result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + ReadAzdHostedSources: stubReadAzdHostedSources(azdHostedSources{ + CfgState: projectContextState{ + Endpoint: "https://legacy.services.ai.azure.com/api/projects/p", + SetAt: "2024-12-31T23:59:59Z", + }, + CfgFound: true, + CfgFromLegacyAgents: true, + }, nil), + }) + require.NoError(t, err) + assert.Equal(t, "https://legacy.services.ai.azure.com/api/projects/p", result.Endpoint) + assert.Equal(t, SourceGlobalConfig, result.Source) + assert.True(t, result.FromLegacyAgentsConfig, + "resolver must propagate the legacy flag so show can surface a migration notice") +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_set.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_set.go index 37cf53cccd4..3a213573e1b 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_set.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_set.go @@ -9,6 +9,8 @@ import ( "fmt" "os" + "azure.ai.projects/internal/exterrors" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" ) @@ -72,7 +74,13 @@ func (a *ProjectSetAction) Run(ctx context.Context) error { azdClient, err := azdext.NewAzdClient() if err != nil { - return fmt.Errorf("failed to create azd client: %w", err) + return exterrors.Dependency( + exterrors.CodeAzdClientFailed, + "could not connect to the azd daemon", + "ensure azd is installed and reachable; "+ + "if you are running this command outside an azd extension host, "+ + "the daemon endpoint may not be configured", + ) } defer azdClient.Close() diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go index 66c10333a6c..dffdf0fbe61 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go @@ -6,13 +6,10 @@ package cmd import ( "context" "encoding/json" - "errors" "fmt" "os" "text/tabwriter" - "azure.ai.projects/internal/exterrors" - "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" ) @@ -27,6 +24,11 @@ type projectShowResult struct { SourceDetail string `json:"sourceDetail"` AzdEnv string `json:"azdEnv"` SetAt string `json:"setAt,omitempty"` + // FromLegacyAgentsConfig is true when the endpoint was sourced from the + // legacy `extensions.ai-agents.project.context` key (left behind by the + // removed `azd ai agent project set` command). Automation can detect this + // to prompt the user to re-run `azd ai project set` and migrate. + FromLegacyAgentsConfig bool `json:"fromLegacyAgentsConfig,omitempty"` } // ProjectShowAction is the action for the `show` command. @@ -67,15 +69,10 @@ that provided it. Useful for debugging which endpoint commands will use.`, func (a *ProjectShowAction) Run(ctx context.Context) error { result, err := resolveProjectEndpoint(ctx, resolveProjectEndpointOpts{}) if err != nil { - // Re-wrap missing-endpoint errors to surface `project set` as the fix. - if localErr, ok := errors.AsType[*azdext.LocalError](err); ok && - localErr.Code == exterrors.CodeMissingProjectEndpoint { - return exterrors.Dependency( - exterrors.CodeMissingProjectEndpoint, - localErr.Message, - "run `azd ai project set ` to persist a default, or "+localErr.Suggestion, - ) - } + // `noProjectEndpointError` already includes "persist a workspace default + // with `azd ai project set `" in its suggestion, so the + // structured error is already actionable for `show`. Return it unchanged + // rather than re-wrapping with a concatenated suggestion. return err } @@ -84,11 +81,12 @@ func (a *ProjectShowAction) Run(ctx context.Context) error { switch a.flags.outputFmt { case "json": out := projectShowResult{ - Endpoint: result.Endpoint, - Source: string(result.Source), - SourceDetail: jsonSourceDetail(result.Source), - AzdEnv: result.AzdEnvName, - SetAt: result.SetAt, + Endpoint: result.Endpoint, + Source: string(result.Source), + SourceDetail: jsonSourceDetail(result.Source), + AzdEnv: result.AzdEnvName, + SetAt: result.SetAt, + FromLegacyAgentsConfig: result.FromLegacyAgentsConfig, } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") @@ -100,7 +98,18 @@ func (a *ProjectShowAction) Run(ctx context.Context) error { if result.Source == SourceGlobalConfig && result.SetAt != "" { fmt.Fprintf(w, "Set at:\t%s\n", result.SetAt) } - return w.Flush() + if err := w.Flush(); err != nil { + return err + } + if result.FromLegacyAgentsConfig { + fmt.Fprintln(os.Stderr, + "notice: this endpoint was read from the legacy "+ + "`extensions.ai-agents.project.context` key written by the "+ + "removed `azd ai agent project set` command. Run "+ + "`azd ai project set ` to migrate it to the "+ + "new `extensions.ai-projects.context` key.") + } + return nil } } diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_unset.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_unset.go index 692810b7a1c..c4c393f1c17 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_unset.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_unset.go @@ -9,6 +9,8 @@ import ( "fmt" "os" + "azure.ai.projects/internal/exterrors" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" ) @@ -18,7 +20,9 @@ type projectUnsetFlags struct { } type projectUnsetResult struct { - Cleared bool `json:"cleared"` + // PreviouslySet reports whether a project endpoint was set before this + // unset operation. The operation succeeds (idempotently) regardless. + PreviouslySet bool `json:"previouslySet"` PreviousEndpoint string `json:"previousEndpoint"` } @@ -59,7 +63,13 @@ is not an error.`, func (a *ProjectUnsetAction) Run(ctx context.Context) error { azdClient, err := azdext.NewAzdClient() if err != nil { - return fmt.Errorf("failed to create azd client: %w", err) + return exterrors.Dependency( + exterrors.CodeAzdClientFailed, + "could not connect to the azd daemon", + "ensure azd is installed and reachable; "+ + "if you are running this command outside an azd extension host, "+ + "the daemon endpoint may not be configured", + ) } defer azdClient.Close() @@ -68,22 +78,22 @@ func (a *ProjectUnsetAction) Run(ctx context.Context) error { return err } - cleared := previous != "" + previouslySet := previous != "" switch a.flags.outputFmt { case "json": result := projectUnsetResult{ - Cleared: cleared, + PreviouslySet: previouslySet, PreviousEndpoint: previous, } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(result) default: - if !cleared { - fmt.Println("No active project endpoint to clear.") - } else { + if previouslySet { fmt.Println("Project endpoint cleared.") + } else { + fmt.Println("No project endpoint was set; nothing to clear.") } return nil } diff --git a/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go index c0155586e72..58f4399963c 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go @@ -16,4 +16,5 @@ const ( // These are paired with [Dependency] when a required external value is missing. const ( CodeMissingProjectEndpoint = "missing_project_endpoint" + CodeAzdClientFailed = "azd_client_failed" ) diff --git a/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go b/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go index 13ee718d1e3..c16eb8dc332 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go +++ b/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go @@ -3,6 +3,13 @@ // Package exterrors provides structured error helpers for the azure.ai.projects extension. // +// This package is a focused subset of the larger `internal/exterrors` package in the +// `azure.ai.agents` extension. The two duplicate the [Validation]/[Dependency] factory +// shapes and the common error codes (e.g. invalid_parameter, missing_project_endpoint, +// azd_client_failed). When a cross-extension shared location is introduced (likely +// under `cli/azd/extensions/shared/exterrors` or inside the main azd module), both +// extensions should switch to that location and these duplicates can be removed. +// // Use plain Go errors until the current code can confidently choose a final // category, code, and suggestion. At that point, create a structured error // with one of the helpers in this package. From 587fa6c84423f8714de277da779f03a1d8061e54 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 20 May 2026 15:33:24 +0800 Subject: [PATCH 3/9] fix(azure.ai.agents): bridge resolver to new extensions.ai-projects.context key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agents-internal project endpoint resolver previously read only extensions.ai-agents.project.context (the legacy key written by the now-removed 'azd ai agent project set' command). After the command migrated to 'azd ai project set' in azure.ai.projects, that writes to extensions.ai-projects.context — leaving agent commands (run, invoke, etc.) unable to resolve an endpoint persisted by the new command. getProjectContext now prefers the new key and falls back to the legacy key, with best-effort error handling on the legacy read so a malformed legacy blob cannot block resolution from the new key, explicit flags, or FOUNDRY_PROJECT_ENDPOINT. CHANGELOG updated to call this out. --- .../extensions/azure.ai.agents/CHANGELOG.md | 2 +- .../internal/cmd/project_context_store.go | 32 +++++++++++++++---- 2 files changed, 27 insertions(+), 7 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index d6fedf53e3a..f28fddd0692 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unreleased -- [[#8243]](https://github.com/Azure/azure-dev/pull/8243) **Removed** `azd ai agent project set|unset|show` commands. The functionality moved to the `azure.ai.projects` extension as `azd ai project set|unset|show`. Endpoints previously persisted by `azd ai agent project set` continue to resolve in the new extension on first read, and the new `azd ai project show` surfaces a one-time notice prompting users to re-run `azd ai project set` to migrate. +- [[#8243]](https://github.com/Azure/azure-dev/pull/8243) **Removed** `azd ai agent project set|unset|show` commands. The functionality moved to the `azure.ai.projects` extension as `azd ai project set|unset|show`. The agents-internal project endpoint resolver now reads the new `extensions.ai-projects.context` key first and falls back to the legacy `extensions.ai-agents.project.context` key, so endpoints previously persisted by `azd ai agent project set` continue to resolve and endpoints set via the new `azd ai project set` are picked up by existing `azd ai agent ...` commands without re-export. `azd ai project show` surfaces a one-time notice prompting users to re-run `azd ai project set` to migrate. ## 0.1.32-preview (2026-05-18) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_context_store.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_context_store.go index ffb963ad7e0..45377b50958 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_context_store.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_context_store.go @@ -10,17 +10,28 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) -// projectContextConfigPath is the UserConfig path for the persisted project context. +// projectsExtensionContextPath is the UserConfig path where the +// `azure.ai.projects` extension persists the project endpoint via +// `azd ai project set`. This is now the canonical location. +const projectsExtensionContextPath = "extensions.ai-projects.context" + +// projectContextConfigPath is the legacy UserConfig path used by the (removed) +// `azd ai agent project set` command. It is read as a fallback so existing +// users who set their endpoint before the command moved keep working. const projectContextConfigPath = configPathPrefix + ".project.context" -// projectContextState is the JSON shape stored at extensions.ai-agents.context -// in ~/.azd/config.json. +// projectContextState is the JSON shape stored at extensions.ai-projects.context +// (and at the legacy extensions.ai-agents.project.context) in ~/.azd/config.json. type projectContextState struct { Endpoint string `json:"endpoint"` SetAt string `json:"setAt"` } // getProjectContext reads the persisted project context from global config. +// It prefers the new `extensions.ai-projects.context` key written by +// `azd ai project set`, and falls back to the legacy +// `extensions.ai-agents.project.context` key for users who set their endpoint +// before the command moved to the azure.ai.projects extension. // Returns (state, true, nil) when present, (zero, false, nil) when absent. func getProjectContext( ctx context.Context, @@ -31,16 +42,25 @@ func getProjectContext( return projectContextState{}, false, fmt.Errorf("getProjectContext: %w", err) } + // New canonical location (written by `azd ai project set`). var state projectContextState - found, err := ch.GetUserJSON(ctx, projectContextConfigPath, &state) + found, err := ch.GetUserJSON(ctx, projectsExtensionContextPath, &state) if err != nil { return projectContextState{}, false, fmt.Errorf("getProjectContext: failed to read config: %w", err) } + if found && state.Endpoint != "" { + return state, true, nil + } - if !found || state.Endpoint == "" { + // Legacy location (written by the removed `azd ai agent project set`). + // Read errors are best-effort: a malformed legacy blob must not break + // resolution from FOUNDRY_PROJECT_ENDPOINT or an explicit flag. + var legacy projectContextState + legacyFound, legacyErr := ch.GetUserJSON(ctx, projectContextConfigPath, &legacy) + if legacyErr != nil || !legacyFound || legacy.Endpoint == "" { return projectContextState{}, false, nil } - return state, true, nil + return legacy, true, nil } From 3c0858f62886a91694653fb1378245e9d0143275 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 20 May 2026 16:06:37 +0800 Subject: [PATCH 4/9] chore: revert CHANGELOG edits and tighten new comments - Revert all CHANGELOG.md edits made in this PR per maintainer request. - Audit and trim comments added by this PR: - Drop the misleading 'parallel tests do not race on a shared global' framing on the test-only stub helper; the resolver tests use t.Setenv and don't t.Parallel, so the claim was forward-looking only. Rewritten to describe what the helper actually does. - Tighten the doc comments on: * resolveProjectEndpointOpts.ReadAzdHostedSources * resolvedEndpoint.FromLegacyAgentsConfig * azdHostedSources.CfgFromLegacyAgents * legacyAgentsContextPath * getLegacyAgentsProjectContext * clearProjectContext * project_show JSON struct and Run error path * exterrors package doc * agents-side getProjectContext + projectContextState + projectsExtensionContextPath + projectContextConfigPath - All cuts remove historical narrative or restated obvious behavior; accuracy-critical invariants (best-effort legacy reads, idempotent unset, where each path is written/read) are retained. --- .../extensions/azure.ai.agents/CHANGELOG.md | 4 --- .../internal/cmd/project_context_store.go | 30 ++++++++----------- .../extensions/azure.ai.projects/CHANGELOG.md | 4 --- .../internal/cmd/project_context_store.go | 23 +++++--------- .../internal/cmd/project_endpoint.go | 8 ++--- .../internal/cmd/project_resolver.go | 27 +++++++---------- .../internal/cmd/project_resolver_test.go | 15 ++++------ .../internal/cmd/project_show.go | 12 +++----- .../internal/exterrors/errors.go | 8 ++--- 9 files changed, 46 insertions(+), 85 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md index f28fddd0692..d3f82e0d74a 100644 --- a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -1,9 +1,5 @@ # Release History -## Unreleased - -- [[#8243]](https://github.com/Azure/azure-dev/pull/8243) **Removed** `azd ai agent project set|unset|show` commands. The functionality moved to the `azure.ai.projects` extension as `azd ai project set|unset|show`. The agents-internal project endpoint resolver now reads the new `extensions.ai-projects.context` key first and falls back to the legacy `extensions.ai-agents.project.context` key, so endpoints previously persisted by `azd ai agent project set` continue to resolve and endpoints set via the new `azd ai project set` are picked up by existing `azd ai agent ...` commands without re-export. `azd ai project show` surfaces a one-time notice prompting users to re-run `azd ai project set` to migrate. - ## 0.1.32-preview (2026-05-18) - [[#8222]](https://github.com/Azure/azure-dev/pull/8222) Add post-init validation to check .NET runtime compatibility with project TargetFramework and show guidance when mismatched. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_context_store.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_context_store.go index 45377b50958..e9376258994 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_context_store.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_context_store.go @@ -10,29 +10,25 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" ) -// projectsExtensionContextPath is the UserConfig path where the -// `azure.ai.projects` extension persists the project endpoint via -// `azd ai project set`. This is now the canonical location. +// projectsExtensionContextPath is the canonical UserConfig path for the +// project endpoint, written by `azd ai project set` in the azure.ai.projects +// extension. const projectsExtensionContextPath = "extensions.ai-projects.context" -// projectContextConfigPath is the legacy UserConfig path used by the (removed) -// `azd ai agent project set` command. It is read as a fallback so existing -// users who set their endpoint before the command moved keep working. +// projectContextConfigPath is the legacy UserConfig path used by the removed +// `azd ai agent project set` command. Read as a fallback only. const projectContextConfigPath = configPathPrefix + ".project.context" -// projectContextState is the JSON shape stored at extensions.ai-projects.context -// (and at the legacy extensions.ai-agents.project.context) in ~/.azd/config.json. +// projectContextState is the JSON shape stored at both +// projectsExtensionContextPath and projectContextConfigPath. type projectContextState struct { Endpoint string `json:"endpoint"` SetAt string `json:"setAt"` } -// getProjectContext reads the persisted project context from global config. -// It prefers the new `extensions.ai-projects.context` key written by -// `azd ai project set`, and falls back to the legacy -// `extensions.ai-agents.project.context` key for users who set their endpoint -// before the command moved to the azure.ai.projects extension. -// Returns (state, true, nil) when present, (zero, false, nil) when absent. +// getProjectContext reads the persisted project context, preferring the new +// canonical key and falling back to the legacy key. Returns (state, true, nil) +// when present, (zero, false, nil) when absent. func getProjectContext( ctx context.Context, azdClient *azdext.AzdClient, @@ -42,7 +38,6 @@ func getProjectContext( return projectContextState{}, false, fmt.Errorf("getProjectContext: %w", err) } - // New canonical location (written by `azd ai project set`). var state projectContextState found, err := ch.GetUserJSON(ctx, projectsExtensionContextPath, &state) if err != nil { @@ -53,9 +48,8 @@ func getProjectContext( return state, true, nil } - // Legacy location (written by the removed `azd ai agent project set`). - // Read errors are best-effort: a malformed legacy blob must not break - // resolution from FOUNDRY_PROJECT_ENDPOINT or an explicit flag. + // Legacy fallback. Errors are swallowed so a malformed legacy blob does + // not block resolution from FOUNDRY_PROJECT_ENDPOINT or an explicit flag. var legacy projectContextState legacyFound, legacyErr := ch.GetUserJSON(ctx, projectContextConfigPath, &legacy) if legacyErr != nil || !legacyFound || legacy.Endpoint == "" { diff --git a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md index da9ba3cefc5..3a52e0f1122 100644 --- a/cli/azd/extensions/azure.ai.projects/CHANGELOG.md +++ b/cli/azd/extensions/azure.ai.projects/CHANGELOG.md @@ -1,7 +1,3 @@ # Release History -## Unreleased - -- [[#8243]](https://github.com/Azure/azure-dev/pull/8243) Add `azd ai project set|unset|show` commands for persisting a default Microsoft Foundry project endpoint. Migrated from `azd ai agent project ...`. Endpoints set by the removed `azd ai agent project set` command are still resolved on first read (with a one-time migration notice surfaced by `azd ai project show`). - ## 0.0.1-preview - Initial Version \ No newline at end of file diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go index 48838d9d8e5..723e1db8673 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go @@ -46,14 +46,10 @@ func getProjectContext( return state, true, nil } -// getLegacyAgentsProjectContext reads the persisted project context written by -// the (now-removed) `azd ai agent project set` command in the azure.ai.agents -// extension. It is used as a one-time read-only fallback to keep existing -// users working immediately after they upgrade. -// -// Returns (state, true, nil) when present, (zero, false, nil) when absent. -// Errors reading the legacy key are best-effort and are not returned: a -// malformed or unavailable legacy value should never break the new resolver. +// getLegacyAgentsProjectContext reads the project context from +// legacyAgentsContextPath. Read errors are swallowed: a malformed legacy +// blob must never break resolution from the new key, explicit flag, or +// FOUNDRY_PROJECT_ENDPOINT. func getLegacyAgentsProjectContext( ctx context.Context, azdClient *azdext.AzdClient, @@ -98,14 +94,11 @@ func setProjectContext( } // clearProjectContext removes the context subtree from global config. -// Returns the previously stored endpoint (empty if none was set, or if the -// previous value could not be decoded). -// The operation is idempotent — calling it when no context is set is not an error. +// Returns the previously stored endpoint (empty if none was set or if the +// stored value could not be decoded). The operation is idempotent. // -// The previous-endpoint read is intentionally best-effort: if the persisted blob -// is malformed (e.g. written by an older or buggy version), `unset` must still be -// able to clear it. Surfacing the decode error here would block users from -// recovering, so we only return errors from the actual `UnsetUser` write. +// The read of the previous value is best-effort so a malformed persisted +// blob does not block `unset` from clearing it. func clearProjectContext( ctx context.Context, azdClient *azdext.AzdClient, diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint.go index bf4100e3695..6c5e094136b 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint.go @@ -15,11 +15,9 @@ import ( // persisted by this extension (stored in ~/.azd/config.json). const configPathPrefix = "extensions.ai-projects" -// legacyAgentsContextPath is the UserConfig path where the (now-removed) -// `azd ai agent project set` command persisted the project endpoint in the -// `azure.ai.agents` extension. The resolver reads it as a one-time fallback -// when no value exists at the new path, so users who set an endpoint via -// the old command keep working after upgrading. +// legacyAgentsContextPath is the UserConfig path used by the removed +// `azd ai agent project set` command. The resolver reads it as a fallback +// when the new path has no value. const legacyAgentsContextPath = "extensions.ai-agents.project.context" // EndpointSource identifies where the resolved project endpoint came from. diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go index 645e5962252..65ef73e17a2 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go @@ -19,11 +19,9 @@ type resolveProjectEndpointOpts struct { // Empty means the flag was not provided. FlagValue string - // ReadAzdHostedSources is a test-injected override for the azd-hosted + // ReadAzdHostedSources lets callers inject a stub for the azd-hosted // lookup (levels 2 + 3). Production callers leave this nil; the resolver - // then falls back to the real [readAzdHostedSources]. Using an - // instance-scoped seam instead of a package-level var keeps the resolver - // safe to call from parallel tests. + // then uses the real [readAzdHostedSources]. ReadAzdHostedSources func(context.Context) (azdHostedSources, error) } @@ -33,10 +31,10 @@ type resolvedEndpoint struct { Source EndpointSource AzdEnvName string SetAt string // RFC3339 timestamp, only meaningful when Source == SourceGlobalConfig - // FromLegacyAgentsConfig is true when the endpoint was sourced from the - // legacy `extensions.ai-agents.project.context` key written by the removed - // `azd ai agent project set` command. Callers should surface a one-time - // notice so users re-run `azd ai project set` to migrate. + // FromLegacyAgentsConfig is true when Source == SourceGlobalConfig and the + // value was read from the legacy `extensions.ai-agents.project.context` + // key. Callers can surface a notice to prompt the user to re-run + // `azd ai project set` to migrate. FromLegacyAgentsConfig bool } @@ -55,9 +53,8 @@ type azdHostedSources struct { // CfgFound indicates whether a non-empty endpoint was found in global config. CfgFound bool // CfgFromLegacyAgents reports whether CfgState was sourced from the - // legacy `extensions.ai-agents.project.context` key (left behind by the - // removed `azd ai agent project set` command). Callers can use this to - // surface a one-time migration notice. Only meaningful when CfgFound is true. + // legacy `extensions.ai-agents.project.context` key. Only meaningful + // when CfgFound is true. CfgFromLegacyAgents bool } @@ -103,11 +100,9 @@ func readAzdHostedSources(ctx context.Context) (azdHostedSources, error) { out.CfgFound = found } - // One-time legacy fallback: if the new key has no value, try the path - // written by the removed `azd ai agent project set` command. Read errors - // are swallowed (best-effort) so a malformed legacy blob never blocks the - // new resolver — users can still resolve via FOUNDRY_PROJECT_ENDPOINT or - // re-run `azd ai project set`. + // Legacy fallback: read the key written by the removed + // `azd ai agent project set` command. Errors are swallowed (best-effort) + // so a malformed legacy blob does not block FOUNDRY_PROJECT_ENDPOINT. if !out.CfgFound { if legacyState, legacyFound := getLegacyAgentsProjectContext(ctx, azdClient); legacyFound { out.CfgState = legacyState diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver_test.go index b4e8a78470d..f4ec96eb950 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver_test.go @@ -16,8 +16,8 @@ import ( ) // stubReadAzdHostedSources returns a function suitable for the -// resolveProjectEndpointOpts.ReadAzdHostedSources seam. Each test gets its own -// captured closure, so parallel tests do not race on a shared global. +// resolveProjectEndpointOpts.ReadAzdHostedSources seam. Each call returns a +// fresh closure so no test mutates state shared with other tests. func stubReadAzdHostedSources( sources azdHostedSources, err error, @@ -27,13 +27,10 @@ func stubReadAzdHostedSources( } } -// isolateFromAzdDaemon returns resolver opts that are isolated from any azd -// daemon the developer machine might have running: -// - Clears AZD_SERVER on the test's env so any real-client paths cannot connect. -// - Returns opts whose ReadAzdHostedSources reports no hosted sources. -// -// Together this ensures the resolver under test only sees the flag and the -// FOUNDRY_PROJECT_ENDPOINT host env var. +// isolateFromAzdDaemon returns resolver opts whose ReadAzdHostedSources +// reports no hosted sources, and clears AZD_SERVER so any code path that +// bypasses the stub cannot reach a real daemon. The resolver under test then +// only sees the flag and FOUNDRY_PROJECT_ENDPOINT. func isolateFromAzdDaemon(t *testing.T) resolveProjectEndpointOpts { t.Helper() t.Setenv("AZD_SERVER", "") diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go index dffdf0fbe61..23c1fdf18bc 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go @@ -24,10 +24,8 @@ type projectShowResult struct { SourceDetail string `json:"sourceDetail"` AzdEnv string `json:"azdEnv"` SetAt string `json:"setAt,omitempty"` - // FromLegacyAgentsConfig is true when the endpoint was sourced from the - // legacy `extensions.ai-agents.project.context` key (left behind by the - // removed `azd ai agent project set` command). Automation can detect this - // to prompt the user to re-run `azd ai project set` and migrate. + // FromLegacyAgentsConfig mirrors [resolvedEndpoint.FromLegacyAgentsConfig] + // so automation can detect the migration prompt without parsing stderr. FromLegacyAgentsConfig bool `json:"fromLegacyAgentsConfig,omitempty"` } @@ -69,10 +67,8 @@ that provided it. Useful for debugging which endpoint commands will use.`, func (a *ProjectShowAction) Run(ctx context.Context) error { result, err := resolveProjectEndpoint(ctx, resolveProjectEndpointOpts{}) if err != nil { - // `noProjectEndpointError` already includes "persist a workspace default - // with `azd ai project set `" in its suggestion, so the - // structured error is already actionable for `show`. Return it unchanged - // rather than re-wrapping with a concatenated suggestion. + // noProjectEndpointError already suggests `azd ai project set`, so + // the structured error is actionable for `show` unchanged. return err } diff --git a/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go b/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go index c16eb8dc332..c230dd8ec15 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go +++ b/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go @@ -3,12 +3,8 @@ // Package exterrors provides structured error helpers for the azure.ai.projects extension. // -// This package is a focused subset of the larger `internal/exterrors` package in the -// `azure.ai.agents` extension. The two duplicate the [Validation]/[Dependency] factory -// shapes and the common error codes (e.g. invalid_parameter, missing_project_endpoint, -// azd_client_failed). When a cross-extension shared location is introduced (likely -// under `cli/azd/extensions/shared/exterrors` or inside the main azd module), both -// extensions should switch to that location and these duplicates can be removed. +// This package mirrors a subset of azure.ai.agents/internal/exterrors so the +// two extensions can be consolidated into a shared package in a follow-up. // // Use plain Go errors until the current code can confidently choose a final // category, code, and suggestion. At that point, create a structured error From 32390f6f4c3b900b1950cec2d2b145fd23a6a8b3 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 20 May 2026 16:47:37 +0800 Subject: [PATCH 5/9] fix(ai.projects): clear legacy endpoint --- .../internal/cmd/project_context_store.go | 45 +++++-- .../cmd/project_context_store_test.go | 110 ++++++++++++++++++ 2 files changed, 144 insertions(+), 11 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store_test.go diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go index 723e1db8673..3a410a93af6 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go @@ -14,6 +14,11 @@ import ( // projectContextConfigPath is the UserConfig path for the persisted project context. const projectContextConfigPath = configPathPrefix + ".context" +type projectContextConfig interface { + GetUserJSON(ctx context.Context, path string, out any) (bool, error) + UnsetUser(ctx context.Context, path string) error +} + // projectContextState is the JSON shape stored at extensions.ai-projects.context // in ~/.azd/config.json. type projectContextState struct { @@ -32,8 +37,16 @@ func getProjectContext( return projectContextState{}, false, fmt.Errorf("getProjectContext: %w", err) } + return readProjectContext(ctx, ch, projectContextConfigPath) +} + +func readProjectContext( + ctx context.Context, + ch projectContextConfig, + path string, +) (projectContextState, bool, error) { var state projectContextState - found, err := ch.GetUserJSON(ctx, projectContextConfigPath, &state) + found, err := ch.GetUserJSON(ctx, path, &state) if err != nil { return projectContextState{}, false, fmt.Errorf("getProjectContext: failed to read config: %w", err) @@ -59,13 +72,12 @@ func getLegacyAgentsProjectContext( return projectContextState{}, false } - var state projectContextState - found, err := ch.GetUserJSON(ctx, legacyAgentsContextPath, &state) - if err != nil || !found || state.Endpoint == "" { + state, found, err := readProjectContext(ctx, ch, legacyAgentsContextPath) + if err != nil { return projectContextState{}, false } - return state, true + return state, found } // setProjectContext persists a validated project endpoint to global config. @@ -103,17 +115,28 @@ func clearProjectContext( ctx context.Context, azdClient *azdext.AzdClient, ) (previousEndpoint string, err error) { - if state, found, readErr := getProjectContext(ctx, azdClient); readErr == nil && found { - previousEndpoint = state.Endpoint - } - ch, chErr := azdext.NewConfigHelper(azdClient) if chErr != nil { return "", fmt.Errorf("clearProjectContext: %w", chErr) } - if err := ch.UnsetUser(ctx, projectContextConfigPath); err != nil { - return "", fmt.Errorf("clearProjectContext: failed to clear config: %w", err) + return clearProjectContextFromConfig(ctx, ch) +} + +func clearProjectContextFromConfig( + ctx context.Context, + ch projectContextConfig, +) (previousEndpoint string, err error) { + if state, found, readErr := readProjectContext(ctx, ch, projectContextConfigPath); readErr == nil && found { + previousEndpoint = state.Endpoint + } else if state, found, readErr := readProjectContext(ctx, ch, legacyAgentsContextPath); readErr == nil && found { + previousEndpoint = state.Endpoint + } + + for _, path := range []string{projectContextConfigPath, legacyAgentsContextPath} { + if err := ch.UnsetUser(ctx, path); err != nil { + return "", fmt.Errorf("clearProjectContext: failed to clear config at %q: %w", path, err) + } } return previousEndpoint, nil diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store_test.go new file mode 100644 index 00000000000..91b4effdff2 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store_test.go @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeProjectContextConfig struct { + values map[string]projectContextState + unsetErrs map[string]error + unsetKeys []string +} + +func (f *fakeProjectContextConfig) GetUserJSON(_ context.Context, path string, out any) (bool, error) { + state, ok := f.values[path] + if !ok { + return false, nil + } + + *out.(*projectContextState) = state + return true, nil +} + +func (f *fakeProjectContextConfig) UnsetUser(_ context.Context, path string) error { + f.unsetKeys = append(f.unsetKeys, path) + if err := f.unsetErrs[path]; err != nil { + return err + } + + delete(f.values, path) + return nil +} + +func TestClearProjectContextFromConfig_ClearsCanonicalAndLegacy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + values map[string]projectContextState + wantPrevious string + }{ + { + name: "canonical only", + values: map[string]projectContextState{ + projectContextConfigPath: {Endpoint: "https://new.services.ai.azure.com/api/projects/p"}, + }, + wantPrevious: "https://new.services.ai.azure.com/api/projects/p", + }, + { + name: "legacy only", + values: map[string]projectContextState{ + legacyAgentsContextPath: {Endpoint: "https://old.services.ai.azure.com/api/projects/p"}, + }, + wantPrevious: "https://old.services.ai.azure.com/api/projects/p", + }, + { + name: "canonical wins previous endpoint", + values: map[string]projectContextState{ + projectContextConfigPath: {Endpoint: "https://new.services.ai.azure.com/api/projects/p"}, + legacyAgentsContextPath: {Endpoint: "https://old.services.ai.azure.com/api/projects/p"}, + }, + wantPrevious: "https://new.services.ai.azure.com/api/projects/p", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := &fakeProjectContextConfig{ + values: tt.values, + unsetErrs: map[string]error{}, + } + + previous, err := clearProjectContextFromConfig(t.Context(), cfg) + + require.NoError(t, err) + assert.Equal(t, tt.wantPrevious, previous) + assert.Equal(t, []string{projectContextConfigPath, legacyAgentsContextPath}, cfg.unsetKeys) + assert.Empty(t, cfg.values) + }) + } +} + +func TestClearProjectContextFromConfig_ReturnsLegacyClearError(t *testing.T) { + t.Parallel() + + sentinel := errors.New("legacy unset failed") + cfg := &fakeProjectContextConfig{ + values: map[string]projectContextState{ + legacyAgentsContextPath: {Endpoint: "https://old.services.ai.azure.com/api/projects/p"}, + }, + unsetErrs: map[string]error{ + legacyAgentsContextPath: sentinel, + }, + } + + previous, err := clearProjectContextFromConfig(t.Context(), cfg) + + require.ErrorIs(t, err, sentinel) + assert.Empty(t, previous) + assert.Equal(t, []string{projectContextConfigPath, legacyAgentsContextPath}, cfg.unsetKeys) +} From cc74d1979e62377f354e918bfc0ce985ef2725d9 Mon Sep 17 00:00:00 2001 From: huimiu Date: Thu, 21 May 2026 12:15:26 +0800 Subject: [PATCH 6/9] feat(ai.projects): auto-migrate legacy ai-agents endpoint The resolver previously detected an endpoint persisted under the legacy `extensions.ai-agents.project.context` key and asked the user to re-run `azd ai project set` to migrate it. Address review feedback by performing the migration automatically the first time the legacy key is observed: copy the value to `extensions.ai-projects.context` and clear the legacy key. The write is best-effort so a transient config failure never breaks the command the user actually invoked. Update the `azd ai project show` notice to confirm the migration instead of prompting the user to run another command, and add unit tests for the new migrate helper covering happy path plus set/unset failure modes. --- .../internal/cmd/project_context_store.go | 40 ++++++++ .../cmd/project_context_store_test.go | 95 +++++++++++++++++++ .../internal/cmd/project_resolver.go | 11 ++- .../internal/cmd/project_show.go | 15 +-- 4 files changed, 152 insertions(+), 9 deletions(-) diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go index 3a410a93af6..d861b369d94 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go @@ -16,6 +16,7 @@ const projectContextConfigPath = configPathPrefix + ".context" type projectContextConfig interface { GetUserJSON(ctx context.Context, path string, out any) (bool, error) + SetUserJSON(ctx context.Context, path string, value any) error UnsetUser(ctx context.Context, path string) error } @@ -80,6 +81,45 @@ func getLegacyAgentsProjectContext( return state, found } +// migrateLegacyAgentsProjectContext copies state from the legacy +// `extensions.ai-agents.project.context` key to the new +// `extensions.ai-projects.context` key and removes the legacy key. The +// operation is best-effort: callers continue with the in-memory value when +// the migration fails so a transient config write error never blocks the +// command the user actually invoked. +func migrateLegacyAgentsProjectContext( + ctx context.Context, + azdClient *azdext.AzdClient, + state projectContextState, +) error { + ch, err := azdext.NewConfigHelper(azdClient) + if err != nil { + return fmt.Errorf("migrateLegacyAgentsProjectContext: %w", err) + } + + return writeMigratedProjectContext(ctx, ch, state) +} + +// writeMigratedProjectContext persists state to projectContextConfigPath and +// removes legacyAgentsContextPath. Split from +// migrateLegacyAgentsProjectContext so tests can drive it with a fake +// projectContextConfig. +func writeMigratedProjectContext( + ctx context.Context, + ch projectContextConfig, + state projectContextState, +) error { + if err := ch.SetUserJSON(ctx, projectContextConfigPath, state); err != nil { + return fmt.Errorf("migrateLegacyAgentsProjectContext: failed to write new key: %w", err) + } + + if err := ch.UnsetUser(ctx, legacyAgentsContextPath); err != nil { + return fmt.Errorf("migrateLegacyAgentsProjectContext: failed to clear legacy key: %w", err) + } + + return nil +} + // setProjectContext persists a validated project endpoint to global config. // The caller is responsible for validating the endpoint before calling this function. // Returns the setAt timestamp that was written to config. diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store_test.go index 91b4effdff2..a7d6942f500 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store_test.go @@ -14,7 +14,9 @@ import ( type fakeProjectContextConfig struct { values map[string]projectContextState + setErrs map[string]error unsetErrs map[string]error + setKeys []string unsetKeys []string } @@ -28,6 +30,24 @@ func (f *fakeProjectContextConfig) GetUserJSON(_ context.Context, path string, o return true, nil } +func (f *fakeProjectContextConfig) SetUserJSON(_ context.Context, path string, value any) error { + f.setKeys = append(f.setKeys, path) + if err := f.setErrs[path]; err != nil { + return err + } + + state, ok := value.(projectContextState) + if !ok { + return errors.New("fakeProjectContextConfig.SetUserJSON: unexpected value type") + } + + if f.values == nil { + f.values = map[string]projectContextState{} + } + f.values[path] = state + return nil +} + func (f *fakeProjectContextConfig) UnsetUser(_ context.Context, path string) error { f.unsetKeys = append(f.unsetKeys, path) if err := f.unsetErrs[path]; err != nil { @@ -108,3 +128,78 @@ func TestClearProjectContextFromConfig_ReturnsLegacyClearError(t *testing.T) { assert.Empty(t, previous) assert.Equal(t, []string{projectContextConfigPath, legacyAgentsContextPath}, cfg.unsetKeys) } + +func TestWriteMigratedProjectContext_CopiesLegacyAndClearsIt(t *testing.T) { + t.Parallel() + + state := projectContextState{ + Endpoint: "https://legacy.services.ai.azure.com/api/projects/p", + SetAt: "2024-12-31T23:59:59Z", + } + cfg := &fakeProjectContextConfig{ + values: map[string]projectContextState{ + legacyAgentsContextPath: state, + }, + } + + require.NoError(t, writeMigratedProjectContext(t.Context(), cfg, state)) + + assert.Equal(t, []string{projectContextConfigPath}, cfg.setKeys) + assert.Equal(t, []string{legacyAgentsContextPath}, cfg.unsetKeys) + assert.Equal(t, state, cfg.values[projectContextConfigPath]) + _, legacyStillPresent := cfg.values[legacyAgentsContextPath] + assert.False(t, legacyStillPresent, "legacy key must be cleared after migration") +} + +func TestWriteMigratedProjectContext_SetFailureLeavesLegacyKey(t *testing.T) { + t.Parallel() + + state := projectContextState{ + Endpoint: "https://legacy.services.ai.azure.com/api/projects/p", + } + sentinel := errors.New("set failed") + cfg := &fakeProjectContextConfig{ + values: map[string]projectContextState{ + legacyAgentsContextPath: state, + }, + setErrs: map[string]error{ + projectContextConfigPath: sentinel, + }, + } + + err := writeMigratedProjectContext(t.Context(), cfg, state) + + require.ErrorIs(t, err, sentinel) + assert.Equal(t, []string{projectContextConfigPath}, cfg.setKeys) + assert.Empty(t, cfg.unsetKeys, + "legacy key must stay until the new key is successfully written") + assert.Equal(t, state, cfg.values[legacyAgentsContextPath]) +} + +func TestWriteMigratedProjectContext_UnsetFailureBubblesUp(t *testing.T) { + t.Parallel() + + state := projectContextState{ + Endpoint: "https://legacy.services.ai.azure.com/api/projects/p", + } + sentinel := errors.New("unset failed") + cfg := &fakeProjectContextConfig{ + values: map[string]projectContextState{ + legacyAgentsContextPath: state, + }, + unsetErrs: map[string]error{ + legacyAgentsContextPath: sentinel, + }, + } + + err := writeMigratedProjectContext(t.Context(), cfg, state) + + require.ErrorIs(t, err, sentinel) + assert.Equal(t, []string{projectContextConfigPath}, cfg.setKeys) + assert.Equal(t, []string{legacyAgentsContextPath}, cfg.unsetKeys) + // New key was written even though the legacy unset failed; the caller + // will retry the cleanup on a subsequent run because the legacy key + // remains present. + assert.Equal(t, state, cfg.values[projectContextConfigPath]) + assert.Equal(t, state, cfg.values[legacyAgentsContextPath]) +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go index 65ef73e17a2..130750a6e7b 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go @@ -33,8 +33,9 @@ type resolvedEndpoint struct { SetAt string // RFC3339 timestamp, only meaningful when Source == SourceGlobalConfig // FromLegacyAgentsConfig is true when Source == SourceGlobalConfig and the // value was read from the legacy `extensions.ai-agents.project.context` - // key. Callers can surface a notice to prompt the user to re-run - // `azd ai project set` to migrate. + // key. The resolver migrates the value to the new key in the same call, + // so this flag is only true on the first run that observes the legacy + // key — callers can use it to surface a one-time confirmation notice. FromLegacyAgentsConfig bool } @@ -108,6 +109,12 @@ func readAzdHostedSources(ctx context.Context) (azdHostedSources, error) { out.CfgState = legacyState out.CfgFound = true out.CfgFromLegacyAgents = true + + // Auto-migrate the legacy value into the new key so subsequent + // invocations resolve from `extensions.ai-projects.context` + // directly. Best-effort: failures are intentionally swallowed + // so a transient config write does not break resolution. + _ = migrateLegacyAgentsProjectContext(ctx, azdClient, legacyState) } } diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go index 23c1fdf18bc..258e8ab1ac2 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go @@ -24,8 +24,10 @@ type projectShowResult struct { SourceDetail string `json:"sourceDetail"` AzdEnv string `json:"azdEnv"` SetAt string `json:"setAt,omitempty"` - // FromLegacyAgentsConfig mirrors [resolvedEndpoint.FromLegacyAgentsConfig] - // so automation can detect the migration prompt without parsing stderr. + // FromLegacyAgentsConfig mirrors [resolvedEndpoint.FromLegacyAgentsConfig]. + // True only on the run that migrated the legacy + // `extensions.ai-agents.project.context` value into the new key, so + // automation can detect the one-time migration notice without parsing stderr. FromLegacyAgentsConfig bool `json:"fromLegacyAgentsConfig,omitempty"` } @@ -99,11 +101,10 @@ func (a *ProjectShowAction) Run(ctx context.Context) error { } if result.FromLegacyAgentsConfig { fmt.Fprintln(os.Stderr, - "notice: this endpoint was read from the legacy "+ - "`extensions.ai-agents.project.context` key written by the "+ - "removed `azd ai agent project set` command. Run "+ - "`azd ai project set ` to migrate it to the "+ - "new `extensions.ai-projects.context` key.") + "notice: migrated this endpoint from the legacy "+ + "`extensions.ai-agents.project.context` key to the new "+ + "`extensions.ai-projects.context` key. Future commands "+ + "will read from the new key directly.") } return nil } From 2a23e08b669168121c81145dd51ffdbc45dabd4f Mon Sep 17 00:00:00 2001 From: huimiu Date: Thu, 21 May 2026 13:09:42 +0800 Subject: [PATCH 7/9] chore(ai.projects): add gitattributes, golangci, and AGENTS docs Align the new extension with sibling AI extensions (`azure.ai.agents`, `azure.ai.training`, etc.): - `.gitattributes` matches `microsoft.azd.extensions` so any future `*.go.tmpl` files keep LF line endings on Windows checkouts. - `.golangci.yaml` mirrors the shared lint config used across the other extensions (gosec/lll/unused/errorlint, lll line-length 220, gofmt formatter). - `AGENTS.md` adapts the `azure.ai.agents` guide to this extension and documents the project endpoint cascade, the persisted-context store invariants (best-effort legacy reads, idempotent unset, best-effort auto-migration), and the release-prep two-PR flow. --- .../azure.ai.projects/.gitattributes | 1 + .../azure.ai.projects/.golangci.yaml | 17 ++ .../extensions/azure.ai.projects/AGENTS.md | 153 ++++++++++++++++++ 3 files changed, 171 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.projects/.gitattributes create mode 100644 cli/azd/extensions/azure.ai.projects/.golangci.yaml create mode 100644 cli/azd/extensions/azure.ai.projects/AGENTS.md diff --git a/cli/azd/extensions/azure.ai.projects/.gitattributes b/cli/azd/extensions/azure.ai.projects/.gitattributes new file mode 100644 index 00000000000..a2905dd15cf --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/.gitattributes @@ -0,0 +1 @@ +*.go.tmpl text eol=lf diff --git a/cli/azd/extensions/azure.ai.projects/.golangci.yaml b/cli/azd/extensions/azure.ai.projects/.golangci.yaml new file mode 100644 index 00000000000..b88a74c6a0b --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/.golangci.yaml @@ -0,0 +1,17 @@ +version: "2" + +linters: + default: none + enable: + - gosec + - lll + - unused + - errorlint + settings: + lll: + line-length: 220 + tab-width: 4 + +formatters: + enable: + - gofmt diff --git a/cli/azd/extensions/azure.ai.projects/AGENTS.md b/cli/azd/extensions/azure.ai.projects/AGENTS.md new file mode 100644 index 00000000000..24c68587479 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/AGENTS.md @@ -0,0 +1,153 @@ +# Azure AI Projects Extension - Agent Instructions + +Use this file together with `cli/azd/AGENTS.md`. This guide supplements the root azd instructions with the conventions that are specific to this extension. + +## Overview + +`azure.ai.projects` is a first-party azd extension under `cli/azd/extensions/azure.ai.projects/`. It runs as a separate Go binary and talks to the azd host over gRPC. + +It owns the Foundry project endpoint context used by other AI extensions (e.g. `azure.ai.agents`). The `azd ai project` commands persist, resolve, and surface the endpoint through a 5-level cascade: + +1. Explicit `--project-endpoint` flag +2. Active azd env's `AZURE_AI_PROJECT_ENDPOINT` +3. Global config: `extensions.ai-projects.context.endpoint` in `~/.azd/config.json` +4. Host environment variable `FOUNDRY_PROJECT_ENDPOINT` +5. Structured error with actionable suggestion + +The resolver also performs a one-time auto-migration from the legacy `extensions.ai-agents.project.context` key (written by the removed `azd ai agent project set` command) into the new key. + +Useful places to start: + +- `internal/cmd/`: Cobra commands, the endpoint resolver, and the config store +- `internal/exterrors/`: structured error factories and extension-specific codes + +## Build and test + +From `cli/azd/extensions/azure.ai.projects`: + +```bash +# Build using developer extension (for local development) +azd x build + +# Or build using Go directly +go build + +# Run unit tests +go test ./... -count=1 +``` + +If extension work depends on a new azd core change, plan for two PRs: + +1. Land the core change in `cli/azd` first. +2. Land the extension change after that, updating this module to the newer azd dependency with `go get github.com/azure/azure-dev/cli/azd && go mod tidy`. + +For local development, draft work, or validating both sides together before the core PR is merged, you may temporarily add: + +```go +replace github.com/azure/azure-dev/cli/azd => ../../ +``` + +That `replace` points this extension at your local `cli/azd` checkout instead of the version in `go.mod`. Do not merge the extension with that `replace` still present. + +## Error handling + +This extension uses `internal/exterrors` so the azd host can show a useful message, attach an optional suggestion, and emit stable telemetry. + +### Default rule + +Use plain Go errors by default. Switch to `exterrors.*` only when the current code can confidently answer all three of these: + +1. What category should telemetry see? +2. What stable error code should be recorded? +3. What suggestion, if any, should the user get? + +That usually means: + +- lower-level helpers return `fmt.Errorf("context: %w", err)` +- user-facing orchestration code in `internal/cmd/` classifies the failure with `exterrors.*` + +### Most important rule + +Create a structured error once, as close as possible to the place where you know the final category, code, and suggestion. + +If `err` is already a structured error, usually return it unchanged. + +Do **not** add context with `fmt.Errorf("context: %w", err)` after `err` is already structured. During gRPC serialization, azd preserves the structured error's own message/code/category, not the outer wrapper text. If you need extra context, include it in the structured error message when you create it. + +### Choosing an Error Type + +| Situation | Prefer | +| --- | --- | +| Invalid endpoint, flag value, or persisted blob | `exterrors.Validation` | +| Missing endpoint across all 5 resolver levels, unavailable azd daemon | `exterrors.Dependency` | +| Auth or tenant/credential failure | `exterrors.Auth` | +| azd/extension version or capability mismatch | `exterrors.Compatibility` | +| User cancellation | `exterrors.Cancelled` | +| Azure SDK HTTP failure | `exterrors.ServiceFromAzure` | +| Unexpected bug or local failure with no better category | `exterrors.Internal` | + +### Error codes + +Define new codes in `internal/exterrors/codes.go`. + +- use lowercase `snake_case` +- describe the specific failure, not the general category +- keep them stable once introduced + +## Persisted project context + +The endpoint store lives at `extensions.ai-projects.context` in `~/.azd/config.json` and is accessed exclusively through helpers in `internal/cmd/project_context_store.go`: + +- `getProjectContext` / `setProjectContext` / `clearProjectContext` — public surface +- `readProjectContext` / `writeMigratedProjectContext` / `clearProjectContextFromConfig` — internal helpers that take a `projectContextConfig` interface so tests can drive them with a fake + +When changing the store: + +- Keep reads of the legacy `extensions.ai-agents.project.context` key best-effort: a malformed legacy blob must never block resolution from the new key, the flag, or `FOUNDRY_PROJECT_ENDPOINT`. +- `clearProjectContext` must remain idempotent and must clear both the new and legacy keys, even when the previous-endpoint read fails (so users can always recover from a corrupted persisted blob). +- The auto-migration in `readAzdHostedSources` is best-effort: a transient write failure must not break the command the user actually invoked. + +## Output: `log` vs `fmt` + +Extensions write directly to stdout/stderr — there is no `Console` abstraction from azd core. + +- **`fmt.Print*`** — user-facing output (stdout). Pair with `output.With*Format` helpers for styled text. +- **`log.Print*`** — developer diagnostics (stderr). Hidden unless `--debug` is set. Never use `log` for anything the user needs to see. +- Do not use `log.Fatal` or `log.Panic` for expected failures — return a structured error via `exterrors` instead. + +```go +// ✅ log — internal detail the user doesn't need to see +log.Printf("config read at %s returned %d bytes", path, n) + +// ✅ fmt — user-facing status and results +fmt.Println(output.WithSuccessFormat("Project endpoint set to %s", endpoint)) + +// ❌ fmt used for debug noise — user sees internal details they can't act on +fmt.Printf("normalized URL: %s\n", normalized) // use log.Printf + +// ❌ log used for user-facing info — user never sees it without --debug +log.Printf("No project endpoint resolved") // return an exterrors.Dependency instead +``` + +## Release preparation + +A new extension release ships in two PRs: + +### PR 1 — Version bump + +Bumps the extension to the new version. Touches only: + +- `version.txt` — new semver string +- `extension.yaml` — `version:` field +- `CHANGELOG.md` — new release section at the top + +Once merged, the team triggers the CI release pipeline, which builds, signs, and publishes the extension binaries as a GitHub release. + +### PR 2 — Registry update + +After the GitHub release is live, a follow-up PR updates `cli/azd/extensions/registry.json` so azd users can install the new version. The contents of that file are produced by running `azd x publish` against the published release artifacts. + +## Other extension conventions + +- Use modern Go 1.26 patterns where they help readability +- When using `PromptSubscription()`, create credentials with `Subscription.UserTenantId`, not `Subscription.TenantId` From 1af5f1b8ba414cbc6b8700c26c409bf890c2ee59 Mon Sep 17 00:00:00 2001 From: huimiu Date: Thu, 21 May 2026 13:46:34 +0800 Subject: [PATCH 8/9] chore: add CODEOWNERS entry for azure.ai.projects extension --- .github/CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fccfbb5be2e..964996e3d76 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -14,6 +14,7 @@ /cli/azd/extensions/azure.ai.agents/ @jongio @wbreza @vhvb1989 @hemarina @weikanglim @JeffreyCA @tg-msft @rajeshkamal5050 @trangevi @trrwilson @therealjohn /cli/azd/extensions/azure.ai.finetune/ @jongio @wbreza @vhvb1989 @hemarina @weikanglim @JeffreyCA @tg-msft @rajeshkamal5050 @trangevi @achauhan-scc @kingernupur @saanikaguptamicrosoft /cli/azd/extensions/azure.ai.models/ @jongio @wbreza @vhvb1989 @hemarina @weikanglim @JeffreyCA @tg-msft @rajeshkamal5050 @trangevi @achauhan-scc @kingernupur @saanikaguptamicrosoft +/cli/azd/extensions/azure.ai.projects/ @jongio @wbreza @vhvb1989 @hemarina @weikanglim @JeffreyCA @tg-msft @rajeshkamal5050 @trangevi @trrwilson @therealjohn @huimiu /cli/azd/extensions/azure.ai.training/ @wbreza @vhvb1989 @hemarina @weikanglim @JeffreyCA @tg-msft @rajeshkamal5050 @trangevi @achauhan-scc @kingernupur @saanikaguptamicrosoft # ── Extensions ──────────────────────────────────────────────────────────────── From ac18d77cf856dc846f82f0f7efa052b8044b0080 Mon Sep 17 00:00:00 2001 From: huimiu Date: Thu, 21 May 2026 13:53:12 +0800 Subject: [PATCH 9/9] docs(ai.projects): align AGENTS.md structure with azure.ai.agents --- .../extensions/azure.ai.projects/AGENTS.md | 63 +++++++++++++------ 1 file changed, 45 insertions(+), 18 deletions(-) diff --git a/cli/azd/extensions/azure.ai.projects/AGENTS.md b/cli/azd/extensions/azure.ai.projects/AGENTS.md index 24c68587479..f0dd2150bcd 100644 --- a/cli/azd/extensions/azure.ai.projects/AGENTS.md +++ b/cli/azd/extensions/azure.ai.projects/AGENTS.md @@ -86,6 +86,33 @@ Do **not** add context with `fmt.Errorf("context: %w", err)` after `err` is alre | Azure SDK HTTP failure | `exterrors.ServiceFromAzure` | | Unexpected bug or local failure with no better category | `exterrors.Internal` | +### Recommended pattern + +```go +func loadEndpoint(raw string) (string, error) { + normalized, _, err := validateProjectEndpoint(raw) + if err != nil { + return "", fmt.Errorf("validate %q: %w", raw, err) + } + + return normalized, nil +} + +func runCommand() error { + endpoint, err := loadEndpoint(rawFlag) + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("project endpoint is invalid: %s", err), + "provide an https:// Foundry project endpoint URL", + ) + } + + _ = endpoint + return nil +} +``` + ### Error codes Define new codes in `internal/exterrors/codes.go`. @@ -107,6 +134,24 @@ When changing the store: - `clearProjectContext` must remain idempotent and must clear both the new and legacy keys, even when the previous-endpoint read fails (so users can always recover from a corrupted persisted blob). - The auto-migration in `readAzdHostedSources` is best-effort: a transient write failure must not break the command the user actually invoked. +## Release preparation + +A new extension release ships in two PRs: + +### PR 1 — Version bump + +Bumps the extension to the new version. Touches only: + +- `version.txt` — new semver string +- `extension.yaml` — `version:` field +- `CHANGELOG.md` — new release section at the top + +Once merged, the team triggers the CI release pipeline, which builds, signs, and publishes the extension binaries as a GitHub release. + +### PR 2 — Registry update + +After the GitHub release is live, a follow-up PR updates `cli/azd/extensions/registry.json` so azd users can install the new version. The contents of that file are produced by running `azd x publish` against the published release artifacts. + ## Output: `log` vs `fmt` Extensions write directly to stdout/stderr — there is no `Console` abstraction from azd core. @@ -129,24 +174,6 @@ fmt.Printf("normalized URL: %s\n", normalized) // use log.Printf log.Printf("No project endpoint resolved") // return an exterrors.Dependency instead ``` -## Release preparation - -A new extension release ships in two PRs: - -### PR 1 — Version bump - -Bumps the extension to the new version. Touches only: - -- `version.txt` — new semver string -- `extension.yaml` — `version:` field -- `CHANGELOG.md` — new release section at the top - -Once merged, the team triggers the CI release pipeline, which builds, signs, and publishes the extension binaries as a GitHub release. - -### PR 2 — Registry update - -After the GitHub release is live, a follow-up PR updates `cli/azd/extensions/registry.json` so azd users can install the new version. The contents of that file are produced by running `azd x publish` against the published release artifacts. - ## Other extension conventions - Use modern Go 1.26 patterns where they help readability