From d40fea46b9fac1d6982e82ce88c788da5ce4f786 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 13 May 2026 17:08:49 +0800 Subject: [PATCH 1/4] feat(project): add commands to manage Foundry project endpoint configuration --- cli/azd/docs/environment-variables.md | 1 + .../internal/cmd/agent_context.go | 160 +++++++++++++++--- .../internal/cmd/agent_endpoint.go | 8 +- .../azure.ai.agents/internal/cmd/project.go | 29 ++++ .../internal/cmd/project_context_store.go | 101 +++++++++++ .../internal/cmd/project_endpoint.go | 135 +++++++++++++++ .../internal/cmd/project_endpoint_test.go | 108 ++++++++++++ .../internal/cmd/project_resolver_test.go | 108 ++++++++++++ .../internal/cmd/project_set.go | 103 +++++++++++ .../internal/cmd/project_set_test.go | 24 +++ .../internal/cmd/project_show.go | 139 +++++++++++++++ .../internal/cmd/project_show_test.go | 92 ++++++++++ .../internal/cmd/project_unset.go | 74 ++++++++ .../internal/cmd/project_unset_test.go | 28 +++ .../azure.ai.agents/internal/cmd/root.go | 1 + .../internal/exterrors/codes.go | 1 + 16 files changed, 1081 insertions(+), 31 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project_context_store.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project_resolver_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project_set.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project_set_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project_show.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project_show_test.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset_test.go diff --git a/cli/azd/docs/environment-variables.md b/cli/azd/docs/environment-variables.md index 41b26a3c236..4175ea283b5 100644 --- a/cli/azd/docs/environment-variables.md +++ b/cli/azd/docs/environment-variables.md @@ -160,6 +160,7 @@ specific version of the tool installed on the machine. | `ENABLE_HOSTED_AGENTS` | If set, indicates that hosted agents are enabled for the current azd environment. | | `ENABLE_CONTAINER_AGENTS` | If set, indicates that container agents are enabled for the current azd environment. | | `AGENT_DEFINITION_PATH` | Path to an agent definition file for AI agent workflows. | +| `FOUNDRY_PROJECT_ENDPOINT` | A host environment variable specifying the Microsoft Foundry project endpoint. Used as a fallback in the endpoint resolution cascade when no azd environment or global config is available. Not read from the azd env, only from the host shell environment. | ## UI Prompt Integration diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go index 2783245d57f..941f8a4bc0f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go @@ -5,13 +5,17 @@ package cmd import ( "context" + "errors" "fmt" + "os" "azureaiagent/internal/pkg/agents/agent_api" "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // DefaultAgentAPIVersion is the default API version for agent operations. @@ -56,9 +60,131 @@ func buildAgentEndpoint(accountName, projectName string) string { return fmt.Sprintf("https://%s.services.ai.azure.com/api/projects/%s", accountName, projectName) } -// resolveAgentEndpoint resolves the agent API endpoint from explicit flags or the azd environment. -// If accountName and projectName are provided, those are used to construct the endpoint. -// Otherwise, it falls back to the AZURE_AI_PROJECT_ENDPOINT environment variable from the current azd environment. +// resolveProjectEndpointOpts controls the 5-level endpoint resolution cascade. +type resolveProjectEndpointOpts struct { + // FlagValue is the value of the -p / --project-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 +} + +// lookupEnvFunc is the function used to read host environment variables. +// It is a package-level variable so tests can override it without OS state. +var lookupEnvFunc = os.Getenv + +// 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. +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. -p / --project-endpoint flag +// 2. Active azd env value (AZURE_AI_PROJECT_ENDPOINT) +// 3. Global config: extensions.ai-agents.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 + } + + // Level 2: active azd environment's AZURE_AI_PROJECT_ENDPOINT. + azdClient, azdErr := azdext.NewAzdClient() + if azdErr == 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 != "" { + normalized, _, err := validateProjectEndpoint(envVal.Value) + if err != nil { + return nil, err + } + return &resolvedEndpoint{ + Endpoint: normalized, + Source: SourceAzdEnv, + AzdEnvName: envResp.Environment.Name, + }, nil + } + } + + // Level 3: global config (requires azd client). + 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 nil, cfgErr + } + } else if found && state.Endpoint != "" { + normalized, _, err := validateProjectEndpoint(state.Endpoint) + if err != nil { + return nil, err + } + return &resolvedEndpoint{ + Endpoint: normalized, + Source: SourceGlobalConfig, + SetAt: state.SetAt, + }, nil + } + } + + // Level 4: host environment variable FOUNDRY_PROJECT_ENDPOINT. + if envVal := lookupEnvFunc("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() +} + +// resolveAgentEndpoint resolves the agent API endpoint from explicit flags or +// the 5-level cascade. If accountName and projectName are provided, those are +// used to construct the endpoint directly (existing behavior). Otherwise the +// cascade is invoked with no flag value. func resolveAgentEndpoint(ctx context.Context, accountName string, projectName string) (string, error) { if accountName != "" && projectName != "" { return buildAgentEndpoint(accountName, projectName), nil @@ -68,34 +194,12 @@ func resolveAgentEndpoint(ctx context.Context, accountName string, projectName s return "", fmt.Errorf("both --account-name and --project-name must be provided together") } - // Fall back to azd environment - azdClient, err := azdext.NewAzdClient() + result, err := resolveProjectEndpoint(ctx, resolveProjectEndpointOpts{}) if err != nil { - return "", fmt.Errorf( - "failed to create azd client: %w\n\nProvide --account-name and --project-name flags, "+ - "or ensure azd environment is configured", err) - } - defer azdClient.Close() - - envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) - if err != nil { - return "", fmt.Errorf( - "failed to get current azd environment: %w\n\nProvide --account-name and --project-name flags, "+ - "or run 'azd init' to set up your environment", err) - } - - envValue, err := azdClient.Environment().GetValue(ctx, &azdext.GetEnvRequest{ - EnvName: envResponse.Environment.Name, - Key: "AZURE_AI_PROJECT_ENDPOINT", - }) - if err != nil || envValue.Value == "" { - return "", fmt.Errorf( - "AZURE_AI_PROJECT_ENDPOINT not found in azd environment '%s'\n\n"+ - "Provide --account-name and --project-name flags, "+ - "or run 'azd ai agent init' to configure the endpoint", envResponse.Environment.Name) + return "", err } - return envValue.Value, nil + return result.Endpoint, nil } // newAgentCredential creates a new Azure credential for agent API calls. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go index f6fceb434d1..47b62cbeedb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go @@ -15,6 +15,10 @@ import ( ) // agentEndpointHostSuffix is the required Foundry host suffix for endpoint URLs. +// parseAgentEndpoint uses this constant directly; it does not call isFoundryHost. +// If additional host suffixes are added to foundryHostSuffixes +// (project_endpoint.go), parseAgentEndpoint must be updated to call isFoundryHost +// instead so the two validators stay in sync. const agentEndpointHostSuffix = ".services.ai.azure.com" // agentEndpointHint is the suggestion appended to most --agent-endpoint validation errors. @@ -181,6 +185,4 @@ func buildInvocationsURL(projectEndpoint, agentName, apiVersion, sid string) str return invURL } -// (isValidAgentNameSegment was removed — agent name validation now delegates -// to agent_yaml.ValidateAgentName so --agent-endpoint enforces the same -// deployable-name format as the rest of the extension.) + diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project.go new file mode 100644 index 00000000000..d117446f75d --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project.go @@ -0,0 +1,29 @@ +// 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 new file mode 100644 index 00000000000..d393a40db88 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/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-agents.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.agents/internal/cmd/project_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint.go new file mode 100644 index 00000000000..b8a6d077e5f --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint.go @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "net/url" + "strings" + + "azureaiagent/internal/exterrors" +) + +// EndpointSource identifies where the resolved project endpoint came from. +type EndpointSource string + +const ( + // SourceFlag means the endpoint came from the -p / --project-endpoint flag. + SourceFlag EndpointSource = "flag" + // SourceAzdEnv means the endpoint came from the active azd environment's + // AZURE_AI_PROJECT_ENDPOINT value. + SourceAzdEnv EndpointSource = "azdEnv" + // SourceGlobalConfig means the endpoint came from ~/.azd/config.json + // (extensions.ai-agents.context.endpoint). + SourceGlobalConfig EndpointSource = "globalConfig" + // SourceFoundryEnv means the endpoint came from the FOUNDRY_PROJECT_ENDPOINT + // host environment variable. + SourceFoundryEnv EndpointSource = "foundryEnv" +) + +// foundryHostSuffixes is the centralized list of accepted Foundry host suffixes +// used by validateProjectEndpoint / isFoundryHost. Note: parseAgentEndpoint in +// agent_endpoint.go has its own agentEndpointHostSuffix constant and must be +// updated separately if new suffixes are added here. +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. The suggestion list is +// generic (no --project-endpoint bullet); callers that expose that flag prepend +// their own line. +func noProjectEndpointError() error { + return exterrors.Dependency( + exterrors.CodeMissingProjectEndpoint, + "no Foundry project endpoint resolved", + "persist a workspace default with `azd ai agent 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.agents/internal/cmd/project_endpoint_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint_test.go new file mode 100644 index 00000000000..b5f8a6de323 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/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" + + "azureaiagent/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.agents/internal/cmd/project_resolver_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_resolver_test.go new file mode 100644 index 00000000000..0832dc6ccf6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_resolver_test.go @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "azureaiagent/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResolveProjectEndpoint_FlagWins(t *testing.T) { + // Even with FOUNDRY_PROJECT_ENDPOINT set, the flag should win. + orig := lookupEnvFunc + lookupEnvFunc = func(key string) string { + if key == "FOUNDRY_PROJECT_ENDPOINT" { + return "https://env.services.ai.azure.com/api/projects/env-proj" + } + return "" + } + t.Cleanup(func() { lookupEnvFunc = orig }) + + 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_FoundryEnvFallback(t *testing.T) { + // No flag, no azd client available → falls back to FOUNDRY_PROJECT_ENDPOINT. + orig := lookupEnvFunc + lookupEnvFunc = func(key string) string { + if key == "FOUNDRY_PROJECT_ENDPOINT" { + return "https://env.services.ai.azure.com/api/projects/env-proj" + } + return "" + } + t.Cleanup(func() { lookupEnvFunc = orig }) + + 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) { + orig := lookupEnvFunc + lookupEnvFunc = func(string) string { return "" } + t.Cleanup(func() { lookupEnvFunc = orig }) + + _, 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) { + _, 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) { + orig := lookupEnvFunc + lookupEnvFunc = func(key string) string { + if key == "FOUNDRY_PROJECT_ENDPOINT" { + return "http://bad.services.ai.azure.com/api/projects/p" + } + return "" + } + t.Cleanup(func() { lookupEnvFunc = orig }) + + _, 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) { + orig := lookupEnvFunc + lookupEnvFunc = func(key string) string { + if key == "FOUNDRY_PROJECT_ENDPOINT" { + return " https://X.SERVICES.AI.AZURE.COM/api/projects/p/ " + } + return "" + } + t.Cleanup(func() { lookupEnvFunc = orig }) + + 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.agents/internal/cmd/project_set.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_set.go new file mode 100644 index 00000000000..906b71a03e8 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_set.go @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type projectSetResult struct { + Endpoint string `json:"endpoint"` + Source string `json:"source"` + SourceDetail string `json:"sourceDetail"` + SetAt string `json:"setAt"` +} + +func newProjectSetCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + + 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 { + rawEndpoint := args[0] + outputFmt := extCtx.OutputFormat + noPrompt := extCtx.NoPrompt + + // Validate the endpoint. + normalized, pathWarning, err := validateProjectEndpoint(rawEndpoint) + if err != nil { + return err + } + + ctx := cmd.Context() + + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + // Persist to global config. + setAt, err := setProjectContext(ctx, azdClient, normalized) + if err != nil { + return err + } + + // Warn if inside an azd project (azd env takes precedence). + if outputFmt != "json" && !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 && outputFmt != "json" && !noPrompt { + fmt.Fprintln(os.Stderr, + "warning: the endpoint path does not look like /api/projects/; "+ + "verify this is the correct Foundry project endpoint.") + } + + switch 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 + } + }, + } + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "table"}, + Default: "table", + }) + + return cmd +} 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 new file mode 100644 index 00000000000..4f697156483 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/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.agents/internal/cmd/project_show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show.go new file mode 100644 index 00000000000..4c5ccf40f87 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show.go @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "text/tabwriter" + + "azureaiagent/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type projectShowResult struct { + Endpoint string `json:"endpoint"` + Source string `json:"source"` + SourceDetail string `json:"sourceDetail"` + AzdEnv string `json:"azdEnv"` + SetAt string `json:"setAt,omitempty"` +} + +func newProjectShowCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + var flagEndpoint string + + 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 + + # Check what a specific endpoint would resolve to + azd ai agent project show -p https://my-project.services.ai.azure.com/api/projects/my-project`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + outputFmt := extCtx.OutputFormat + ctx := cmd.Context() + + result, err := resolveProjectEndpoint(ctx, resolveProjectEndpointOpts{ + FlagValue: flagEndpoint, + }) + if err != nil { + // project show exposes -p / --project-endpoint, so prepend that as the + // first suggestion bullet to the generic missing-endpoint error. + var localErr *azdext.LocalError + if errors.As(err, &localErr) && + localErr.Code == exterrors.CodeMissingProjectEndpoint { + return exterrors.Dependency( + exterrors.CodeMissingProjectEndpoint, + localErr.Message, + "pass --project-endpoint on this command, or "+localErr.Suggestion, + ) + } + return err + } + + sourceDetail := humanSourceDetail(result.Source, result.AzdEnvName) + + switch 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() + } + }, + } + + cmd.Flags().StringVarP( + &flagEndpoint, "project-endpoint", "p", "", + "Override the endpoint for this command (useful for debugging)", + ) + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "table"}, + Default: "table", + }) + + return cmd +} + +// 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 new file mode 100644 index 00000000000..55be096587f --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/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_HasProjectEndpointFlag(t *testing.T) { + t.Parallel() + cmd := newProjectShowCommand(nil) + f := cmd.Flags().Lookup("project-endpoint") + assert.NotNil(t, f, "--project-endpoint flag should be registered") + assert.Equal(t, "p", f.Shorthand) +} + +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 new file mode 100644 index 00000000000..5ab6048f888 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset.go @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/spf13/cobra" +) + +type projectUnsetResult struct { + Cleared bool `json:"cleared"` + PreviousEndpoint string `json:"previousEndpoint"` +} + +func newProjectUnsetCommand(extCtx *azdext.ExtensionContext) *cobra.Command { + extCtx = ensureExtensionContext(extCtx) + + 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 { + outputFmt := extCtx.OutputFormat + ctx := cmd.Context() + + 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 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 + } + }, + } + + azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ + Name: "output", + AllowedValues: []string{"json", "table"}, + Default: "table", + }) + + return cmd +} 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 new file mode 100644 index 00000000000..1e0a0c68603 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/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.agents/internal/cmd/root.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go index d65d1c0b8e5..c63ea180146 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go @@ -60,6 +60,7 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newMonitorCommand(extCtx)) rootCmd.AddCommand(newFilesCommand(extCtx)) rootCmd.AddCommand(newSessionCommand(extCtx)) + rootCmd.AddCommand(newProjectCommand(extCtx)) return rootCmd } diff --git a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index 3a1714c6929..44a98be6cad 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -52,6 +52,7 @@ const ( CodeMissingAiProjectId = "missing_ai_project_id" CodeMissingAzureSubscription = "missing_azure_subscription_id" CodeMissingAgentEnvVars = "missing_agent_env_vars" + CodeMissingProjectEndpoint = "missing_project_endpoint" CodeGitHubDownloadFailed = "github_download_failed" CodeScaffoldTemplateFailed = "scaffold_template_failed" CodePromptFailed = "prompt_failed" From dfa8d8cd106b61ad622cce59cfc32e5ced791a43 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 13 May 2026 18:48:21 +0800 Subject: [PATCH 2/4] address PR review comments - project_show.go: use errors.AsType[*azdext.LocalError] per AGENTS.md guidance - agent_context.go: extract azd-hosted source lookup (levels 2 + 3) into a stubbable seam (readAzdHostedSourcesFunc) for testability - project_resolver_test.go: add unit tests for AZURE_AI_PROJECT_ENDPOINT from azd env (success + invalid) and global config (success + invalid), plus a hosted-sources error propagation test; isolate non-azd tests from any developer-machine AZD_SERVER by clearing the env var and stubbing the seam Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../internal/cmd/agent_context.go | 133 +++++++++---- .../internal/cmd/project_resolver_test.go | 188 ++++++++++++++++-- .../internal/cmd/project_show.go | 3 +- 3 files changed, 259 insertions(+), 65 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go index 941f8a4bc0f..79f4027932a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go @@ -79,6 +79,71 @@ type resolvedEndpoint struct { // It is a package-level variable so tests can override it without OS state. var lookupEnvFunc = os.Getenv +// 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. @@ -117,52 +182,36 @@ func resolveProjectEndpoint( }, 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. - azdClient, azdErr := azdext.NewAzdClient() - if azdErr == 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 != "" { - normalized, _, err := validateProjectEndpoint(envVal.Value) - if err != nil { - return nil, err - } - return &resolvedEndpoint{ - Endpoint: normalized, - Source: SourceAzdEnv, - AzdEnvName: envResp.Environment.Name, - }, nil - } + 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 (requires azd client). - 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 nil, cfgErr - } - } else if found && state.Endpoint != "" { - normalized, _, err := validateProjectEndpoint(state.Endpoint) - if err != nil { - return nil, err - } - return &resolvedEndpoint{ - Endpoint: normalized, - Source: SourceGlobalConfig, - SetAt: state.SetAt, - }, 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. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_resolver_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_resolver_test.go index 0832dc6ccf6..8bf4d9eb551 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_resolver_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_resolver_test.go @@ -4,6 +4,8 @@ package cmd import ( + "context" + "errors" "testing" "azureaiagent/internal/exterrors" @@ -13,16 +15,51 @@ import ( "github.com/stretchr/testify/require" ) -func TestResolveProjectEndpoint_FlagWins(t *testing.T) { - // Even with FOUNDRY_PROJECT_ENDPOINT set, the flag should win. +// 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 }) +} + +// stubLookupEnv replaces lookupEnvFunc for the duration of the test. +func stubLookupEnv(t *testing.T, fn func(string) string) { + t.Helper() orig := lookupEnvFunc - lookupEnvFunc = func(key string) string { + lookupEnvFunc = fn + t.Cleanup(func() { lookupEnvFunc = 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. + stubLookupEnv(t, func(key string) string { if key == "FOUNDRY_PROJECT_ENDPOINT" { return "https://env.services.ai.azure.com/api/projects/env-proj" } return "" - } - t.Cleanup(func() { lookupEnvFunc = orig }) + }) + 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", @@ -32,16 +69,126 @@ func TestResolveProjectEndpoint_FlagWins(t *testing.T) { 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. + stubLookupEnv(t, func(key string) string { + if key == "FOUNDRY_PROJECT_ENDPOINT" { + return "https://foundry.services.ai.azure.com/api/projects/p" + } + return "" + }) + 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). + stubLookupEnv(t, func(key string) string { + if key == "FOUNDRY_PROJECT_ENDPOINT" { + // Set, but resolver must NOT fall through to this when level 2 is invalid. + return "https://foundry.services.ai.azure.com/api/projects/p" + } + return "" + }) + 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. + stubLookupEnv(t, func(key string) string { + if key == "FOUNDRY_PROJECT_ENDPOINT" { + return "https://foundry.services.ai.azure.com/api/projects/p" + } + return "" + }) + 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). + stubLookupEnv(t, func(key string) string { + if key == "FOUNDRY_PROJECT_ENDPOINT" { + return "https://foundry.services.ai.azure.com/api/projects/p" + } + return "" + }) + 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. + stubLookupEnv(t, func(key string) string { + if key == "FOUNDRY_PROJECT_ENDPOINT" { + return "https://foundry.services.ai.azure.com/api/projects/p" + } + return "" + }) + 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 client available → falls back to FOUNDRY_PROJECT_ENDPOINT. - orig := lookupEnvFunc - lookupEnvFunc = func(key string) string { + // No flag, no azd-hosted sources → falls back to FOUNDRY_PROJECT_ENDPOINT. + isolateFromAzdDaemon(t) + stubLookupEnv(t, func(key string) string { if key == "FOUNDRY_PROJECT_ENDPOINT" { return "https://env.services.ai.azure.com/api/projects/env-proj" } return "" - } - t.Cleanup(func() { lookupEnvFunc = orig }) + }) result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) require.NoError(t, err) @@ -50,9 +197,8 @@ func TestResolveProjectEndpoint_FoundryEnvFallback(t *testing.T) { } func TestResolveProjectEndpoint_NothingResolvable(t *testing.T) { - orig := lookupEnvFunc - lookupEnvFunc = func(string) string { return "" } - t.Cleanup(func() { lookupEnvFunc = orig }) + isolateFromAzdDaemon(t) + stubLookupEnv(t, func(string) string { return "" }) _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) require.Error(t, err) @@ -63,6 +209,7 @@ 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", }) @@ -74,14 +221,13 @@ func TestResolveProjectEndpoint_InvalidFlagRejected(t *testing.T) { } func TestResolveProjectEndpoint_InvalidFoundryEnvRejected(t *testing.T) { - orig := lookupEnvFunc - lookupEnvFunc = func(key string) string { + isolateFromAzdDaemon(t) + stubLookupEnv(t, func(key string) string { if key == "FOUNDRY_PROJECT_ENDPOINT" { return "http://bad.services.ai.azure.com/api/projects/p" } return "" - } - t.Cleanup(func() { lookupEnvFunc = orig }) + }) _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) require.Error(t, err) @@ -92,17 +238,17 @@ func TestResolveProjectEndpoint_InvalidFoundryEnvRejected(t *testing.T) { } func TestResolveProjectEndpoint_FoundryEnvNormalized(t *testing.T) { - orig := lookupEnvFunc - lookupEnvFunc = func(key string) string { + isolateFromAzdDaemon(t) + stubLookupEnv(t, func(key string) string { if key == "FOUNDRY_PROJECT_ENDPOINT" { return " https://X.SERVICES.AI.AZURE.COM/api/projects/p/ " } return "" - } - t.Cleanup(func() { lookupEnvFunc = orig }) + }) 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.agents/internal/cmd/project_show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show.go index 4c5ccf40f87..f38ae340690 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show.go @@ -49,8 +49,7 @@ that provided it. Useful for debugging which endpoint agent commands will use.`, if err != nil { // project show exposes -p / --project-endpoint, so prepend that as the // first suggestion bullet to the generic missing-endpoint error. - var localErr *azdext.LocalError - if errors.As(err, &localErr) && + if localErr, ok := errors.AsType[*azdext.LocalError](err); ok && localErr.Code == exterrors.CodeMissingProjectEndpoint { return exterrors.Dependency( exterrors.CodeMissingProjectEndpoint, From 52ef58d27c1d2349c50ee13dcbc57a1d4e1bccb8 Mon Sep 17 00:00:00 2001 From: huimiu Date: Wed, 13 May 2026 19:29:07 +0800 Subject: [PATCH 3/4] fix: apply gofmt Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/azure.ai.agents/internal/cmd/agent_endpoint.go | 2 -- .../azure.ai.agents/internal/cmd/project_resolver_test.go | 1 - cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go | 2 +- 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go index 47b62cbeedb..f4ba0947401 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go @@ -184,5 +184,3 @@ func buildInvocationsURL(projectEndpoint, agentName, apiVersion, sid string) str } return invURL } - - diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_resolver_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_resolver_test.go index 8bf4d9eb551..1dffd7dfb1e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_resolver_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_resolver_test.go @@ -251,4 +251,3 @@ func TestResolveProjectEndpoint_FoundryEnvNormalized(t *testing.T) { 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.agents/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go index 44a98be6cad..14727c38353 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go +++ b/cli/azd/extensions/azure.ai.agents/internal/exterrors/codes.go @@ -52,7 +52,7 @@ const ( CodeMissingAiProjectId = "missing_ai_project_id" CodeMissingAzureSubscription = "missing_azure_subscription_id" CodeMissingAgentEnvVars = "missing_agent_env_vars" - CodeMissingProjectEndpoint = "missing_project_endpoint" + CodeMissingProjectEndpoint = "missing_project_endpoint" CodeGitHubDownloadFailed = "github_download_failed" CodeScaffoldTemplateFailed = "scaffold_template_failed" CodePromptFailed = "prompt_failed" From ec09606e9cc3ed1fcb23980bf55c6d04f4d17ebb Mon Sep 17 00:00:00 2001 From: huimiu Date: Thu, 14 May 2026 15:14:29 +0800 Subject: [PATCH 4/4] refactor: adress comments --- .../internal/cmd/agent_context.go | 7 +- .../internal/cmd/agent_endpoint.go | 13 +- .../internal/cmd/project_context_store.go | 2 +- .../internal/cmd/project_endpoint.go | 7 +- .../internal/cmd/project_resolver_test.go | 75 ++-------- .../internal/cmd/project_set.go | 131 ++++++++++-------- .../internal/cmd/project_show.go | 110 ++++++++------- .../internal/cmd/project_show_test.go | 8 +- .../internal/cmd/project_unset.go | 80 ++++++----- 9 files changed, 204 insertions(+), 229 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go index 79f4027932a..76541225ea0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_context.go @@ -75,10 +75,6 @@ type resolvedEndpoint struct { SetAt string // RFC3339 timestamp, only meaningful when Source == SourceGlobalConfig } -// lookupEnvFunc is the function used to read host environment variables. -// It is a package-level variable so tests can override it without OS state. -var lookupEnvFunc = os.Getenv - // 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 @@ -147,6 +143,7 @@ func readAzdHostedSources(ctx context.Context) (azdHostedSources, error) { // 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 { @@ -215,7 +212,7 @@ func resolveProjectEndpoint( } // Level 4: host environment variable FOUNDRY_PROJECT_ENDPOINT. - if envVal := lookupEnvFunc("FOUNDRY_PROJECT_ENDPOINT"); envVal != "" { + if envVal := os.Getenv("FOUNDRY_PROJECT_ENDPOINT"); envVal != "" { normalized, _, err := validateProjectEndpoint(envVal) if err != nil { return nil, err diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go index f4ba0947401..9e590a65217 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/agent_endpoint.go @@ -14,12 +14,9 @@ import ( "azureaiagent/internal/pkg/agents/agent_yaml" ) -// agentEndpointHostSuffix is the required Foundry host suffix for endpoint URLs. -// parseAgentEndpoint uses this constant directly; it does not call isFoundryHost. -// If additional host suffixes are added to foundryHostSuffixes -// (project_endpoint.go), parseAgentEndpoint must be updated to call isFoundryHost -// instead so the two validators stay in sync. -const agentEndpointHostSuffix = ".services.ai.azure.com" +// agentEndpointHostHint is the example Foundry host suffix shown in validation +// error messages. Actual host membership is checked via isFoundryHost (project_endpoint.go). +const agentEndpointHostHint = ".services.ai.azure.com" // agentEndpointHint is the suggestion appended to most --agent-endpoint validation errors. // `azd ai agent show` persistently prints the agent endpoint URL, so it's the right @@ -81,10 +78,10 @@ func parseAgentEndpoint(rawURL string) (*parsedAgentEndpoint, error) { } host := strings.ToLower(u.Hostname()) - if host == "" || !strings.HasSuffix(host, agentEndpointHostSuffix) { + if host == "" || !isFoundryHost(host) { return nil, exterrors.Validation( exterrors.CodeInvalidParameter, - fmt.Sprintf("--agent-endpoint host %q is not a Foundry host (*%s)", u.Hostname(), agentEndpointHostSuffix), + fmt.Sprintf("--agent-endpoint host %q is not a Foundry host (*%s)", u.Hostname(), agentEndpointHostHint), agentEndpointHint, ) } 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 d393a40db88..82d46b5a406 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 @@ -12,7 +12,7 @@ import ( ) // projectContextConfigPath is the UserConfig path for the persisted project context. -const projectContextConfigPath = configPathPrefix + ".context" +const projectContextConfigPath = configPathPrefix + ".project.context" // projectContextState is the JSON shape stored at extensions.ai-agents.context // in ~/.azd/config.json. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint.go index b8a6d077e5f..89762f4f34c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_endpoint.go @@ -28,10 +28,9 @@ const ( SourceFoundryEnv EndpointSource = "foundryEnv" ) -// foundryHostSuffixes is the centralized list of accepted Foundry host suffixes -// used by validateProjectEndpoint / isFoundryHost. Note: parseAgentEndpoint in -// agent_endpoint.go has its own agentEndpointHostSuffix constant and must be -// updated separately if new suffixes are added here. +// foundryHostSuffixes is the authoritative list of accepted Foundry host suffixes. +// isFoundryHost checks this list; both validateProjectEndpoint and parseAgentEndpoint +// (agent_endpoint.go) call isFoundryHost, so all validators stay in sync automatically. var foundryHostSuffixes = []string{ ".services.ai.azure.com", } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_resolver_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_resolver_test.go index 1dffd7dfb1e..2b512254a8f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_resolver_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_resolver_test.go @@ -26,14 +26,6 @@ func stubAzdHostedSources(t *testing.T, sources azdHostedSources, err error) { t.Cleanup(func() { readAzdHostedSourcesFunc = orig }) } -// stubLookupEnv replaces lookupEnvFunc for the duration of the test. -func stubLookupEnv(t *testing.T, fn func(string) string) { - t.Helper() - orig := lookupEnvFunc - lookupEnvFunc = fn - t.Cleanup(func() { lookupEnvFunc = 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: @@ -50,12 +42,7 @@ func isolateFromAzdDaemon(t *testing.T) { func TestResolveProjectEndpoint_FlagWins(t *testing.T) { // Even with FOUNDRY_PROJECT_ENDPOINT and azd-hosted sources set, the flag should win. - stubLookupEnv(t, func(key string) string { - if key == "FOUNDRY_PROJECT_ENDPOINT" { - return "https://env.services.ai.azure.com/api/projects/env-proj" - } - return "" - }) + 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", @@ -72,12 +59,7 @@ func TestResolveProjectEndpoint_FlagWins(t *testing.T) { 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. - stubLookupEnv(t, func(key string) string { - if key == "FOUNDRY_PROJECT_ENDPOINT" { - return "https://foundry.services.ai.azure.com/api/projects/p" - } - return "" - }) + 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", @@ -97,13 +79,8 @@ func TestResolveProjectEndpoint_AzdEnvResolves(t *testing.T) { func TestResolveProjectEndpoint_AzdEnvInvalidRejected(t *testing.T) { // Level 2 invalid values are hard errors (no silent fallback to lower levels). - stubLookupEnv(t, func(key string) string { - if key == "FOUNDRY_PROJECT_ENDPOINT" { - // Set, but resolver must NOT fall through to this when level 2 is invalid. - return "https://foundry.services.ai.azure.com/api/projects/p" - } - return "" - }) + // 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", @@ -119,12 +96,7 @@ 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. - stubLookupEnv(t, func(key string) string { - if key == "FOUNDRY_PROJECT_ENDPOINT" { - return "https://foundry.services.ai.azure.com/api/projects/p" - } - return "" - }) + 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/ ", @@ -142,12 +114,7 @@ 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). - stubLookupEnv(t, func(key string) string { - if key == "FOUNDRY_PROJECT_ENDPOINT" { - return "https://foundry.services.ai.azure.com/api/projects/p" - } - return "" - }) + 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", @@ -167,12 +134,7 @@ func TestResolveProjectEndpoint_GlobalConfigInvalidRejected(t *testing.T) { 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. - stubLookupEnv(t, func(key string) string { - if key == "FOUNDRY_PROJECT_ENDPOINT" { - return "https://foundry.services.ai.azure.com/api/projects/p" - } - return "" - }) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") sentinel := errors.New("boom") stubAzdHostedSources(t, azdHostedSources{}, sentinel) @@ -183,12 +145,7 @@ func TestResolveProjectEndpoint_HostedSourcesErrorPropagates(t *testing.T) { func TestResolveProjectEndpoint_FoundryEnvFallback(t *testing.T) { // No flag, no azd-hosted sources → falls back to FOUNDRY_PROJECT_ENDPOINT. isolateFromAzdDaemon(t) - stubLookupEnv(t, func(key string) string { - if key == "FOUNDRY_PROJECT_ENDPOINT" { - return "https://env.services.ai.azure.com/api/projects/env-proj" - } - return "" - }) + 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) @@ -198,7 +155,7 @@ func TestResolveProjectEndpoint_FoundryEnvFallback(t *testing.T) { func TestResolveProjectEndpoint_NothingResolvable(t *testing.T) { isolateFromAzdDaemon(t) - stubLookupEnv(t, func(string) string { return "" }) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "") _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) require.Error(t, err) @@ -222,12 +179,7 @@ func TestResolveProjectEndpoint_InvalidFlagRejected(t *testing.T) { func TestResolveProjectEndpoint_InvalidFoundryEnvRejected(t *testing.T) { isolateFromAzdDaemon(t) - stubLookupEnv(t, func(key string) string { - if key == "FOUNDRY_PROJECT_ENDPOINT" { - return "http://bad.services.ai.azure.com/api/projects/p" - } - return "" - }) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "http://bad.services.ai.azure.com/api/projects/p") _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) require.Error(t, err) @@ -239,12 +191,7 @@ func TestResolveProjectEndpoint_InvalidFoundryEnvRejected(t *testing.T) { func TestResolveProjectEndpoint_FoundryEnvNormalized(t *testing.T) { isolateFromAzdDaemon(t) - stubLookupEnv(t, func(key string) string { - if key == "FOUNDRY_PROJECT_ENDPOINT" { - return " https://X.SERVICES.AI.AZURE.COM/api/projects/p/ " - } - return "" - }) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", " https://X.SERVICES.AI.AZURE.COM/api/projects/p/ ") result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{}) require.NoError(t, err) 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 index 906b71a03e8..3d4f73ae742 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_set.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_set.go @@ -4,6 +4,7 @@ package cmd import ( + "context" "encoding/json" "fmt" "os" @@ -12,6 +13,12 @@ import ( "github.com/spf13/cobra" ) +type projectSetFlags struct { + endpoint string + outputFmt string + noPrompt bool +} + type projectSetResult struct { Endpoint string `json:"endpoint"` Source string `json:"source"` @@ -19,8 +26,14 @@ type projectSetResult struct { 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 ", @@ -32,64 +45,12 @@ azd environment or explicit flag is available.`, 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 { - rawEndpoint := args[0] - outputFmt := extCtx.OutputFormat - noPrompt := extCtx.NoPrompt - - // Validate the endpoint. - normalized, pathWarning, err := validateProjectEndpoint(rawEndpoint) - if err != nil { - return err - } - - ctx := cmd.Context() - - azdClient, err := azdext.NewAzdClient() - if err != nil { - return fmt.Errorf("failed to create azd client: %w", err) - } - defer azdClient.Close() - - // Persist to global config. - setAt, err := setProjectContext(ctx, azdClient, normalized) - if err != nil { - return err - } - - // Warn if inside an azd project (azd env takes precedence). - if outputFmt != "json" && !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 && outputFmt != "json" && !noPrompt { - fmt.Fprintln(os.Stderr, - "warning: the endpoint path does not look like /api/projects/; "+ - "verify this is the correct Foundry project endpoint.") - } - - switch 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 - } + flags.endpoint = args[0] + flags.outputFmt = extCtx.OutputFormat + flags.noPrompt = extCtx.NoPrompt + + action := &ProjectSetAction{flags: flags} + return action.Run(cmd.Context()) }, } @@ -101,3 +62,57 @@ azd environment or explicit flag is available.`, 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_show.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show.go index f38ae340690..c97ca71cd41 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show.go @@ -4,6 +4,7 @@ package cmd import ( + "context" "encoding/json" "errors" "fmt" @@ -16,6 +17,10 @@ import ( "github.com/spf13/cobra" ) +type projectShowFlags struct { + outputFmt string +} + type projectShowResult struct { Endpoint string `json:"endpoint"` Source string `json:"source"` @@ -24,9 +29,14 @@ type projectShowResult struct { 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) - var flagEndpoint string + flags := &projectShowFlags{} cmd := &cobra.Command{ Use: "show", @@ -34,63 +44,16 @@ func newProjectShowCommand(extCtx *azdext.ExtensionContext) *cobra.Command { 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 - - # Check what a specific endpoint would resolve to - azd ai agent project show -p https://my-project.services.ai.azure.com/api/projects/my-project`, + azd ai agent project show`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - outputFmt := extCtx.OutputFormat - ctx := cmd.Context() - - result, err := resolveProjectEndpoint(ctx, resolveProjectEndpointOpts{ - FlagValue: flagEndpoint, - }) - if err != nil { - // project show exposes -p / --project-endpoint, so prepend that as the - // first suggestion bullet to the generic missing-endpoint error. - if localErr, ok := errors.AsType[*azdext.LocalError](err); ok && - localErr.Code == exterrors.CodeMissingProjectEndpoint { - return exterrors.Dependency( - exterrors.CodeMissingProjectEndpoint, - localErr.Message, - "pass --project-endpoint on this command, or "+localErr.Suggestion, - ) - } - return err - } - - sourceDetail := humanSourceDetail(result.Source, result.AzdEnvName) - - switch 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() - } + flags.outputFmt = extCtx.OutputFormat + + action := &ProjectShowAction{flags: flags} + return action.Run(cmd.Context()) }, } - cmd.Flags().StringVarP( - &flagEndpoint, "project-endpoint", "p", "", - "Override the endpoint for this command (useful for debugging)", - ) - azdext.RegisterFlagOptions(cmd, azdext.FlagOptions{ Name: "output", AllowedValues: []string{"json", "table"}, @@ -100,6 +63,47 @@ that provided it. Useful for debugging which endpoint agent commands will use.`, 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 { 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 index 55be096587f..67018e7c46c 100644 --- 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 @@ -27,12 +27,12 @@ func TestProjectShowCommand_DefaultOutputFormat(t *testing.T) { assertOutputFlagOptions(t, cmd, "table", []string{"json", "table"}) } -func TestProjectShowCommand_HasProjectEndpointFlag(t *testing.T) { +func TestProjectShowCommand_HasNoProjectEndpointFlag(t *testing.T) { t.Parallel() cmd := newProjectShowCommand(nil) - f := cmd.Flags().Lookup("project-endpoint") - assert.NotNil(t, f, "--project-endpoint flag should be registered") - assert.Equal(t, "p", f.Shorthand) + 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) { 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 index 5ab6048f888..50b6e9bfffd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset.go @@ -4,6 +4,7 @@ package cmd import ( + "context" "encoding/json" "fmt" "os" @@ -12,13 +13,23 @@ import ( "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", @@ -28,39 +39,10 @@ func newProjectUnsetCommand(extCtx *azdext.ExtensionContext) *cobra.Command { is not an error.`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - outputFmt := extCtx.OutputFormat - ctx := cmd.Context() + flags.outputFmt = extCtx.OutputFormat - 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 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 - } + action := &ProjectUnsetAction{flags: flags} + return action.Run(cmd.Context()) }, } @@ -72,3 +54,37 @@ is not an error.`, 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 + } +}