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..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 @@ -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,46 +60,192 @@ 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. -func resolveAgentEndpoint(ctx context.Context, accountName string, projectName string) (string, error) { - if accountName != "" && projectName != "" { - return buildAgentEndpoint(accountName, projectName), nil - } +// 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 +} - if accountName != "" || projectName != "" { - return "", fmt.Errorf("both --account-name and --project-name must be provided together") - } +// 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 - // Fall back to azd environment azdClient, err := azdext.NewAzdClient() 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) + // No azd client at all => no hosted sources, not an error. + return out, nil } defer azdClient.Close() - envResponse, err := azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) + 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. -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 + } + + // Levels 2 + 3: azd-hosted sources (active env, then global config). + sources, err := readAzdHostedSourcesFunc(ctx) 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) + return nil, 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) + // 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() +} + +// 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 + } + + if accountName != "" || projectName != "" { + return "", fmt.Errorf("both --account-name and --project-name must be provided together") + } + + result, err := resolveProjectEndpoint(ctx, resolveProjectEndpointOpts{}) + if err != nil { + 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..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,8 +14,9 @@ import ( "azureaiagent/internal/pkg/agents/agent_yaml" ) -// agentEndpointHostSuffix is the required Foundry host suffix for endpoint URLs. -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 @@ -77,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, ) } @@ -180,7 +181,3 @@ 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..82d46b5a406 --- /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 + ".project.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..89762f4f34c --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/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" + + "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 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", +} + +// 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..2b512254a8f --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/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" + + "azureaiagent/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.agents/internal/cmd/project_set.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_set.go new file mode 100644 index 00000000000..3d4f73ae742 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/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 `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 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..c97ca71cd41 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/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" + + "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 new file mode 100644 index 00000000000..67018e7c46c --- /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_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 new file mode 100644 index 00000000000..50b6e9bfffd --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/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 `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 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..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,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"