diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0451ef68ad0..7e8f5d97ef6 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -15,6 +15,7 @@ /cli/azd/extensions/azure.ai.toolboxes/ @JeffreyCA @trangevi @trrwilson @therealjohn /cli/azd/extensions/azure.ai.finetune/ @JeffreyCA @trangevi @achauhan-scc @kingernupur @saanikaguptamicrosoft /cli/azd/extensions/azure.ai.models/ @JeffreyCA @trangevi @achauhan-scc @kingernupur @saanikaguptamicrosoft +/cli/azd/extensions/azure.ai.projects/ @JeffreyCA @trangevi @trrwilson @therealjohn @huimiu /cli/azd/extensions/azure.ai.training/ @JeffreyCA @trangevi @achauhan-scc @kingernupur @saanikaguptamicrosoft # ── Extensions ──────────────────────────────────────────────────────────────── diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project.go deleted file mode 100644 index d117446f75d..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project.go +++ /dev/null @@ -1,29 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package cmd - -import ( - "github.com/azure/azure-dev/cli/azd/pkg/azdext" - "github.com/spf13/cobra" -) - -func newProjectCommand(extCtx *azdext.ExtensionContext) *cobra.Command { - extCtx = ensureExtensionContext(extCtx) - - cmd := &cobra.Command{ - Use: "project [options]", - Short: "Manage the default Microsoft Foundry project endpoint.", - Long: `Manage the default Microsoft Foundry project endpoint. - -These commands persist a workspace-level project endpoint in the azd global -config (~/.azd/config.json) so that other agent commands can resolve it -without requiring an azd environment or explicit flags.`, - } - - cmd.AddCommand(newProjectSetCommand(extCtx)) - cmd.AddCommand(newProjectUnsetCommand(extCtx)) - cmd.AddCommand(newProjectShowCommand(extCtx)) - - return cmd -} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_context_store.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_context_store.go index 82d46b5a406..e9376258994 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_context_store.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/project_context_store.go @@ -6,23 +6,29 @@ 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. +// projectsExtensionContextPath is the canonical UserConfig path for the +// project endpoint, written by `azd ai project set` in the azure.ai.projects +// extension. +const projectsExtensionContextPath = "extensions.ai-projects.context" + +// projectContextConfigPath is the legacy UserConfig path used by the removed +// `azd ai agent project set` command. Read as a fallback only. const projectContextConfigPath = configPathPrefix + ".project.context" -// projectContextState is the JSON shape stored at extensions.ai-agents.context -// in ~/.azd/config.json. +// projectContextState is the JSON shape stored at both +// projectsExtensionContextPath and projectContextConfigPath. 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. +// getProjectContext reads the persisted project context, preferring the new +// canonical key and falling back to the legacy key. Returns (state, true, nil) +// when present, (zero, false, nil) when absent. func getProjectContext( ctx context.Context, azdClient *azdext.AzdClient, @@ -33,69 +39,22 @@ func getProjectContext( } var state projectContextState - found, err := ch.GetUserJSON(ctx, projectContextConfigPath, &state) + found, err := ch.GetUserJSON(ctx, projectsExtensionContextPath, &state) if err != nil { return projectContextState{}, false, fmt.Errorf("getProjectContext: failed to read config: %w", err) } - - if !found || state.Endpoint == "" { - return 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) + if found && state.Endpoint != "" { + return state, true, nil } - 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) + // Legacy fallback. Errors are swallowed so a malformed legacy blob does + // not block resolution from FOUNDRY_PROJECT_ENDPOINT or an explicit flag. + var legacy projectContextState + legacyFound, legacyErr := ch.GetUserJSON(ctx, projectContextConfigPath, &legacy) + if legacyErr != nil || !legacyFound || legacy.Endpoint == "" { + return projectContextState{}, false, nil } - return previousEndpoint, nil + return legacy, true, nil } 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 7f0cb81ea5d..06e20eea8cb 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go @@ -62,7 +62,6 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newMonitorCommand(extCtx)) rootCmd.AddCommand(newFilesCommand(extCtx)) rootCmd.AddCommand(newSessionCommand(extCtx)) - rootCmd.AddCommand(newProjectCommand(extCtx)) rootCmd.AddCommand(newSampleCommand(extCtx)) // Connection commands — in separate package for easy lift-and-shift later. diff --git a/cli/azd/extensions/azure.ai.projects/.gitattributes b/cli/azd/extensions/azure.ai.projects/.gitattributes new file mode 100644 index 00000000000..a2905dd15cf --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/.gitattributes @@ -0,0 +1 @@ +*.go.tmpl text eol=lf diff --git a/cli/azd/extensions/azure.ai.projects/.golangci.yaml b/cli/azd/extensions/azure.ai.projects/.golangci.yaml new file mode 100644 index 00000000000..b88a74c6a0b --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/.golangci.yaml @@ -0,0 +1,17 @@ +version: "2" + +linters: + default: none + enable: + - gosec + - lll + - unused + - errorlint + settings: + lll: + line-length: 220 + tab-width: 4 + +formatters: + enable: + - gofmt diff --git a/cli/azd/extensions/azure.ai.projects/AGENTS.md b/cli/azd/extensions/azure.ai.projects/AGENTS.md new file mode 100644 index 00000000000..f0dd2150bcd --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/AGENTS.md @@ -0,0 +1,180 @@ +# Azure AI Projects Extension - Agent Instructions + +Use this file together with `cli/azd/AGENTS.md`. This guide supplements the root azd instructions with the conventions that are specific to this extension. + +## Overview + +`azure.ai.projects` is a first-party azd extension under `cli/azd/extensions/azure.ai.projects/`. It runs as a separate Go binary and talks to the azd host over gRPC. + +It owns the Foundry project endpoint context used by other AI extensions (e.g. `azure.ai.agents`). The `azd ai project` commands persist, resolve, and surface the endpoint through a 5-level cascade: + +1. Explicit `--project-endpoint` flag +2. Active azd env's `AZURE_AI_PROJECT_ENDPOINT` +3. Global config: `extensions.ai-projects.context.endpoint` in `~/.azd/config.json` +4. Host environment variable `FOUNDRY_PROJECT_ENDPOINT` +5. Structured error with actionable suggestion + +The resolver also performs a one-time auto-migration from the legacy `extensions.ai-agents.project.context` key (written by the removed `azd ai agent project set` command) into the new key. + +Useful places to start: + +- `internal/cmd/`: Cobra commands, the endpoint resolver, and the config store +- `internal/exterrors/`: structured error factories and extension-specific codes + +## Build and test + +From `cli/azd/extensions/azure.ai.projects`: + +```bash +# Build using developer extension (for local development) +azd x build + +# Or build using Go directly +go build + +# Run unit tests +go test ./... -count=1 +``` + +If extension work depends on a new azd core change, plan for two PRs: + +1. Land the core change in `cli/azd` first. +2. Land the extension change after that, updating this module to the newer azd dependency with `go get github.com/azure/azure-dev/cli/azd && go mod tidy`. + +For local development, draft work, or validating both sides together before the core PR is merged, you may temporarily add: + +```go +replace github.com/azure/azure-dev/cli/azd => ../../ +``` + +That `replace` points this extension at your local `cli/azd` checkout instead of the version in `go.mod`. Do not merge the extension with that `replace` still present. + +## Error handling + +This extension uses `internal/exterrors` so the azd host can show a useful message, attach an optional suggestion, and emit stable telemetry. + +### Default rule + +Use plain Go errors by default. Switch to `exterrors.*` only when the current code can confidently answer all three of these: + +1. What category should telemetry see? +2. What stable error code should be recorded? +3. What suggestion, if any, should the user get? + +That usually means: + +- lower-level helpers return `fmt.Errorf("context: %w", err)` +- user-facing orchestration code in `internal/cmd/` classifies the failure with `exterrors.*` + +### Most important rule + +Create a structured error once, as close as possible to the place where you know the final category, code, and suggestion. + +If `err` is already a structured error, usually return it unchanged. + +Do **not** add context with `fmt.Errorf("context: %w", err)` after `err` is already structured. During gRPC serialization, azd preserves the structured error's own message/code/category, not the outer wrapper text. If you need extra context, include it in the structured error message when you create it. + +### Choosing an Error Type + +| Situation | Prefer | +| --- | --- | +| Invalid endpoint, flag value, or persisted blob | `exterrors.Validation` | +| Missing endpoint across all 5 resolver levels, unavailable azd daemon | `exterrors.Dependency` | +| Auth or tenant/credential failure | `exterrors.Auth` | +| azd/extension version or capability mismatch | `exterrors.Compatibility` | +| User cancellation | `exterrors.Cancelled` | +| Azure SDK HTTP failure | `exterrors.ServiceFromAzure` | +| Unexpected bug or local failure with no better category | `exterrors.Internal` | + +### Recommended pattern + +```go +func loadEndpoint(raw string) (string, error) { + normalized, _, err := validateProjectEndpoint(raw) + if err != nil { + return "", fmt.Errorf("validate %q: %w", raw, err) + } + + return normalized, nil +} + +func runCommand() error { + endpoint, err := loadEndpoint(rawFlag) + if err != nil { + return exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("project endpoint is invalid: %s", err), + "provide an https:// Foundry project endpoint URL", + ) + } + + _ = endpoint + return nil +} +``` + +### Error codes + +Define new codes in `internal/exterrors/codes.go`. + +- use lowercase `snake_case` +- describe the specific failure, not the general category +- keep them stable once introduced + +## Persisted project context + +The endpoint store lives at `extensions.ai-projects.context` in `~/.azd/config.json` and is accessed exclusively through helpers in `internal/cmd/project_context_store.go`: + +- `getProjectContext` / `setProjectContext` / `clearProjectContext` — public surface +- `readProjectContext` / `writeMigratedProjectContext` / `clearProjectContextFromConfig` — internal helpers that take a `projectContextConfig` interface so tests can drive them with a fake + +When changing the store: + +- Keep reads of the legacy `extensions.ai-agents.project.context` key best-effort: a malformed legacy blob must never block resolution from the new key, the flag, or `FOUNDRY_PROJECT_ENDPOINT`. +- `clearProjectContext` must remain idempotent and must clear both the new and legacy keys, even when the previous-endpoint read fails (so users can always recover from a corrupted persisted blob). +- The auto-migration in `readAzdHostedSources` is best-effort: a transient write failure must not break the command the user actually invoked. + +## Release preparation + +A new extension release ships in two PRs: + +### PR 1 — Version bump + +Bumps the extension to the new version. Touches only: + +- `version.txt` — new semver string +- `extension.yaml` — `version:` field +- `CHANGELOG.md` — new release section at the top + +Once merged, the team triggers the CI release pipeline, which builds, signs, and publishes the extension binaries as a GitHub release. + +### PR 2 — Registry update + +After the GitHub release is live, a follow-up PR updates `cli/azd/extensions/registry.json` so azd users can install the new version. The contents of that file are produced by running `azd x publish` against the published release artifacts. + +## Output: `log` vs `fmt` + +Extensions write directly to stdout/stderr — there is no `Console` abstraction from azd core. + +- **`fmt.Print*`** — user-facing output (stdout). Pair with `output.With*Format` helpers for styled text. +- **`log.Print*`** — developer diagnostics (stderr). Hidden unless `--debug` is set. Never use `log` for anything the user needs to see. +- Do not use `log.Fatal` or `log.Panic` for expected failures — return a structured error via `exterrors` instead. + +```go +// ✅ log — internal detail the user doesn't need to see +log.Printf("config read at %s returned %d bytes", path, n) + +// ✅ fmt — user-facing status and results +fmt.Println(output.WithSuccessFormat("Project endpoint set to %s", endpoint)) + +// ❌ fmt used for debug noise — user sees internal details they can't act on +fmt.Printf("normalized URL: %s\n", normalized) // use log.Printf + +// ❌ log used for user-facing info — user never sees it without --debug +log.Printf("No project endpoint resolved") // return an exterrors.Dependency instead +``` + +## Other extension conventions + +- Use modern Go 1.26 patterns where they help readability +- When using `PromptSubscription()`, create credentials with `Subscription.UserTenantId`, not `Subscription.TenantId` diff --git a/cli/azd/extensions/azure.ai.projects/cspell.yaml b/cli/azd/extensions/azure.ai.projects/cspell.yaml index 258d305a22d..d0510f7e9e3 100644 --- a/cli/azd/extensions/azure.ai.projects/cspell.yaml +++ b/cli/azd/extensions/azure.ai.projects/cspell.yaml @@ -1,2 +1,4 @@ import: ../../.vscode/cspell.yaml -words: [] +words: + - exterrors + - idempotently diff --git a/cli/azd/extensions/azure.ai.projects/go.mod b/cli/azd/extensions/azure.ai.projects/go.mod index 4f724b0d5d0..fb1af3c91ec 100644 --- a/cli/azd/extensions/azure.ai.projects/go.mod +++ b/cli/azd/extensions/azure.ai.projects/go.mod @@ -1,19 +1,19 @@ module azure.ai.projects - go 1.26.1 require ( github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 - github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 - github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 github.com/azure/azure-dev/cli/azd v1.25.0 github.com/fatih/color v1.18.0 github.com/spf13/cobra v1.10.1 + github.com/stretchr/testify v1.11.1 + google.golang.org/grpc v1.80.0 ) require ( github.com/AlecAivazis/survey/v2 v2.3.7 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 // indirect github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 // indirect @@ -76,7 +76,6 @@ require ( github.com/sethvargo/go-retry v0.3.0 // indirect github.com/spf13/cast v1.10.0 // indirect github.com/spf13/pflag v1.0.10 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/theckman/yacspin v0.13.12 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect @@ -98,7 +97,6 @@ require ( golang.org/x/text v0.35.0 // indirect golang.org/x/time v0.9.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/cli/azd/extensions/azure.ai.projects/go.sum b/cli/azd/extensions/azure.ai.projects/go.sum index 09262a16074..2f50b8bcfd1 100644 --- a/cli/azd/extensions/azure.ai.projects/go.sum +++ b/cli/azd/extensions/azure.ai.projects/go.sum @@ -15,8 +15,6 @@ github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0 h1:2qsI github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal/v3 v3.1.0/go.mod h1:AW8VEadnhw9xox+VaVd9sP7NjzOAnaZBLRH6Tq3cJ38= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0 h1:nnQ9vXH039UrEFxi08pPuZBE7VfqSJt343uJLw0rhWI= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.5.0/go.mod h1:4YIVtzMFVsPwBvitCDX7J9sqthSj43QD1sP6fYc1egc= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0 h1:pPvTJ1dY0sA35JOeFq6TsY2xj6Z85Yo23Pj4wCCvu4o= -github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/managementgroups/armmanagementgroups v1.0.0/go.mod h1:mLfWfj8v3jfWKsL9G4eoBoXVcsqcIUTapmdKy7uGOp0= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 h1:Dd+RhdJn0OTtVGaeDLZpcumkIVCtA/3/Fo42+eoYvVM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0/go.mod h1:5kakwfW5CjC9KK+Q4wjXAg+ShuIm2mBMua0ZFj2C8PE= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 h1:wxQx2Bt4xzPIKvW59WQf1tJNx/ZZKPfN+EhPX3Z6CYY= diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/extension_context.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/extension_context.go new file mode 100644 index 00000000000..93dd4d56487 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/extension_context.go @@ -0,0 +1,18 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import "github.com/azure/azure-dev/cli/azd/pkg/azdext" + +// ensureExtensionContext returns a non-nil [azdext.ExtensionContext] so command +// constructors can be safely invoked from tests with a nil receiver. The SDK's +// [azdext.NewExtensionRootCommand] populates the real context (and its env-var +// fallback) before any leaf RunE runs, so tests that don't exercise RunE can +// safely pass nil here. +func ensureExtensionContext(extCtx *azdext.ExtensionContext) *azdext.ExtensionContext { + if extCtx == nil { + return &azdext.ExtensionContext{} + } + return extCtx +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/flag_options_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/flag_options_test.go new file mode 100644 index 00000000000..7d1abb3d95d --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/flag_options_test.go @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "encoding/json" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// assertOutputFlagOptions verifies that cmd has the per-command --output flag +// configuration registered via [azdext.RegisterFlagOptions]. The SDK records +// these as cobra annotations rather than as a redeclared flag value, so we +// inspect cmd.Annotations directly rather than reading from cmd.Flags(). +func assertOutputFlagOptions(t *testing.T, cmd *cobra.Command, wantDefault string, wantAllowed []string) { + t.Helper() + require.NotNil(t, cmd) + require.NotNil(t, cmd.Annotations, "cmd.Annotations should be set by RegisterFlagOptions") + + got := cmd.Annotations["azdext.default/output"] + assert.Equal(t, wantDefault, got, "default for --output") + + allowedJSON := cmd.Annotations["azdext.allowed-values/output"] + require.NotEmpty(t, allowedJSON, "allowed values for --output should be set") + var allowed []string + require.NoError(t, json.Unmarshal([]byte(allowedJSON), &allowed)) + assert.Equal(t, wantAllowed, allowed, "allowed values for --output") +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go new file mode 100644 index 00000000000..d861b369d94 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store.go @@ -0,0 +1,183 @@ +// 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" + +type projectContextConfig interface { + GetUserJSON(ctx context.Context, path string, out any) (bool, error) + SetUserJSON(ctx context.Context, path string, value any) error + UnsetUser(ctx context.Context, path string) error +} + +// projectContextState is the JSON shape stored at extensions.ai-projects.context +// in ~/.azd/config.json. +type projectContextState struct { + Endpoint string `json:"endpoint"` + SetAt string `json:"setAt"` +} + +// getProjectContext reads the persisted project context from global config. +// Returns (state, true, nil) when present, (zero, false, nil) when absent. +func getProjectContext( + ctx context.Context, + azdClient *azdext.AzdClient, +) (projectContextState, bool, error) { + ch, err := azdext.NewConfigHelper(azdClient) + if err != nil { + return projectContextState{}, false, fmt.Errorf("getProjectContext: %w", err) + } + + return readProjectContext(ctx, ch, projectContextConfigPath) +} + +func readProjectContext( + ctx context.Context, + ch projectContextConfig, + path string, +) (projectContextState, bool, error) { + var state projectContextState + found, err := ch.GetUserJSON(ctx, path, &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 +} + +// getLegacyAgentsProjectContext reads the project context from +// legacyAgentsContextPath. Read errors are swallowed: a malformed legacy +// blob must never break resolution from the new key, explicit flag, or +// FOUNDRY_PROJECT_ENDPOINT. +func getLegacyAgentsProjectContext( + ctx context.Context, + azdClient *azdext.AzdClient, +) (projectContextState, bool) { + ch, err := azdext.NewConfigHelper(azdClient) + if err != nil { + return projectContextState{}, false + } + + state, found, err := readProjectContext(ctx, ch, legacyAgentsContextPath) + if err != nil { + return projectContextState{}, false + } + + return state, found +} + +// migrateLegacyAgentsProjectContext copies state from the legacy +// `extensions.ai-agents.project.context` key to the new +// `extensions.ai-projects.context` key and removes the legacy key. The +// operation is best-effort: callers continue with the in-memory value when +// the migration fails so a transient config write error never blocks the +// command the user actually invoked. +func migrateLegacyAgentsProjectContext( + ctx context.Context, + azdClient *azdext.AzdClient, + state projectContextState, +) error { + ch, err := azdext.NewConfigHelper(azdClient) + if err != nil { + return fmt.Errorf("migrateLegacyAgentsProjectContext: %w", err) + } + + return writeMigratedProjectContext(ctx, ch, state) +} + +// writeMigratedProjectContext persists state to projectContextConfigPath and +// removes legacyAgentsContextPath. Split from +// migrateLegacyAgentsProjectContext so tests can drive it with a fake +// projectContextConfig. +func writeMigratedProjectContext( + ctx context.Context, + ch projectContextConfig, + state projectContextState, +) error { + if err := ch.SetUserJSON(ctx, projectContextConfigPath, state); err != nil { + return fmt.Errorf("migrateLegacyAgentsProjectContext: failed to write new key: %w", err) + } + + if err := ch.UnsetUser(ctx, legacyAgentsContextPath); err != nil { + return fmt.Errorf("migrateLegacyAgentsProjectContext: failed to clear legacy key: %w", err) + } + + return nil +} + +// setProjectContext persists a validated project endpoint to global config. +// The caller is responsible for validating the endpoint before calling this function. +// Returns the setAt timestamp that was written to config. +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 or if the +// stored value could not be decoded). The operation is idempotent. +// +// The read of the previous value is best-effort so a malformed persisted +// blob does not block `unset` from clearing it. +func clearProjectContext( + ctx context.Context, + azdClient *azdext.AzdClient, +) (previousEndpoint string, err error) { + ch, chErr := azdext.NewConfigHelper(azdClient) + if chErr != nil { + return "", fmt.Errorf("clearProjectContext: %w", chErr) + } + + return clearProjectContextFromConfig(ctx, ch) +} + +func clearProjectContextFromConfig( + ctx context.Context, + ch projectContextConfig, +) (previousEndpoint string, err error) { + if state, found, readErr := readProjectContext(ctx, ch, projectContextConfigPath); readErr == nil && found { + previousEndpoint = state.Endpoint + } else if state, found, readErr := readProjectContext(ctx, ch, legacyAgentsContextPath); readErr == nil && found { + previousEndpoint = state.Endpoint + } + + for _, path := range []string{projectContextConfigPath, legacyAgentsContextPath} { + if err := ch.UnsetUser(ctx, path); err != nil { + return "", fmt.Errorf("clearProjectContext: failed to clear config at %q: %w", path, err) + } + } + + return previousEndpoint, nil +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store_test.go new file mode 100644 index 00000000000..a7d6942f500 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_context_store_test.go @@ -0,0 +1,205 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type fakeProjectContextConfig struct { + values map[string]projectContextState + setErrs map[string]error + unsetErrs map[string]error + setKeys []string + unsetKeys []string +} + +func (f *fakeProjectContextConfig) GetUserJSON(_ context.Context, path string, out any) (bool, error) { + state, ok := f.values[path] + if !ok { + return false, nil + } + + *out.(*projectContextState) = state + return true, nil +} + +func (f *fakeProjectContextConfig) SetUserJSON(_ context.Context, path string, value any) error { + f.setKeys = append(f.setKeys, path) + if err := f.setErrs[path]; err != nil { + return err + } + + state, ok := value.(projectContextState) + if !ok { + return errors.New("fakeProjectContextConfig.SetUserJSON: unexpected value type") + } + + if f.values == nil { + f.values = map[string]projectContextState{} + } + f.values[path] = state + return nil +} + +func (f *fakeProjectContextConfig) UnsetUser(_ context.Context, path string) error { + f.unsetKeys = append(f.unsetKeys, path) + if err := f.unsetErrs[path]; err != nil { + return err + } + + delete(f.values, path) + return nil +} + +func TestClearProjectContextFromConfig_ClearsCanonicalAndLegacy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + values map[string]projectContextState + wantPrevious string + }{ + { + name: "canonical only", + values: map[string]projectContextState{ + projectContextConfigPath: {Endpoint: "https://new.services.ai.azure.com/api/projects/p"}, + }, + wantPrevious: "https://new.services.ai.azure.com/api/projects/p", + }, + { + name: "legacy only", + values: map[string]projectContextState{ + legacyAgentsContextPath: {Endpoint: "https://old.services.ai.azure.com/api/projects/p"}, + }, + wantPrevious: "https://old.services.ai.azure.com/api/projects/p", + }, + { + name: "canonical wins previous endpoint", + values: map[string]projectContextState{ + projectContextConfigPath: {Endpoint: "https://new.services.ai.azure.com/api/projects/p"}, + legacyAgentsContextPath: {Endpoint: "https://old.services.ai.azure.com/api/projects/p"}, + }, + wantPrevious: "https://new.services.ai.azure.com/api/projects/p", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cfg := &fakeProjectContextConfig{ + values: tt.values, + unsetErrs: map[string]error{}, + } + + previous, err := clearProjectContextFromConfig(t.Context(), cfg) + + require.NoError(t, err) + assert.Equal(t, tt.wantPrevious, previous) + assert.Equal(t, []string{projectContextConfigPath, legacyAgentsContextPath}, cfg.unsetKeys) + assert.Empty(t, cfg.values) + }) + } +} + +func TestClearProjectContextFromConfig_ReturnsLegacyClearError(t *testing.T) { + t.Parallel() + + sentinel := errors.New("legacy unset failed") + cfg := &fakeProjectContextConfig{ + values: map[string]projectContextState{ + legacyAgentsContextPath: {Endpoint: "https://old.services.ai.azure.com/api/projects/p"}, + }, + unsetErrs: map[string]error{ + legacyAgentsContextPath: sentinel, + }, + } + + previous, err := clearProjectContextFromConfig(t.Context(), cfg) + + require.ErrorIs(t, err, sentinel) + assert.Empty(t, previous) + assert.Equal(t, []string{projectContextConfigPath, legacyAgentsContextPath}, cfg.unsetKeys) +} + +func TestWriteMigratedProjectContext_CopiesLegacyAndClearsIt(t *testing.T) { + t.Parallel() + + state := projectContextState{ + Endpoint: "https://legacy.services.ai.azure.com/api/projects/p", + SetAt: "2024-12-31T23:59:59Z", + } + cfg := &fakeProjectContextConfig{ + values: map[string]projectContextState{ + legacyAgentsContextPath: state, + }, + } + + require.NoError(t, writeMigratedProjectContext(t.Context(), cfg, state)) + + assert.Equal(t, []string{projectContextConfigPath}, cfg.setKeys) + assert.Equal(t, []string{legacyAgentsContextPath}, cfg.unsetKeys) + assert.Equal(t, state, cfg.values[projectContextConfigPath]) + _, legacyStillPresent := cfg.values[legacyAgentsContextPath] + assert.False(t, legacyStillPresent, "legacy key must be cleared after migration") +} + +func TestWriteMigratedProjectContext_SetFailureLeavesLegacyKey(t *testing.T) { + t.Parallel() + + state := projectContextState{ + Endpoint: "https://legacy.services.ai.azure.com/api/projects/p", + } + sentinel := errors.New("set failed") + cfg := &fakeProjectContextConfig{ + values: map[string]projectContextState{ + legacyAgentsContextPath: state, + }, + setErrs: map[string]error{ + projectContextConfigPath: sentinel, + }, + } + + err := writeMigratedProjectContext(t.Context(), cfg, state) + + require.ErrorIs(t, err, sentinel) + assert.Equal(t, []string{projectContextConfigPath}, cfg.setKeys) + assert.Empty(t, cfg.unsetKeys, + "legacy key must stay until the new key is successfully written") + assert.Equal(t, state, cfg.values[legacyAgentsContextPath]) +} + +func TestWriteMigratedProjectContext_UnsetFailureBubblesUp(t *testing.T) { + t.Parallel() + + state := projectContextState{ + Endpoint: "https://legacy.services.ai.azure.com/api/projects/p", + } + sentinel := errors.New("unset failed") + cfg := &fakeProjectContextConfig{ + values: map[string]projectContextState{ + legacyAgentsContextPath: state, + }, + unsetErrs: map[string]error{ + legacyAgentsContextPath: sentinel, + }, + } + + err := writeMigratedProjectContext(t.Context(), cfg, state) + + require.ErrorIs(t, err, sentinel) + assert.Equal(t, []string{projectContextConfigPath}, cfg.setKeys) + assert.Equal(t, []string{legacyAgentsContextPath}, cfg.unsetKeys) + // New key was written even though the legacy unset failed; the caller + // will retry the cleanup on a subsequent run because the legacy key + // remains present. + assert.Equal(t, state, cfg.values[projectContextConfigPath]) + assert.Equal(t, state, cfg.values[legacyAgentsContextPath]) +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint.go new file mode 100644 index 00000000000..6c5e094136b --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint.go @@ -0,0 +1,139 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "fmt" + "net/url" + "strings" + + "azure.ai.projects/internal/exterrors" +) + +// configPathPrefix is the UserConfig namespace for the Foundry project context +// persisted by this extension (stored in ~/.azd/config.json). +const configPathPrefix = "extensions.ai-projects" + +// legacyAgentsContextPath is the UserConfig path used by the removed +// `azd ai agent project set` command. The resolver reads it as a fallback +// when the new path has no value. +const legacyAgentsContextPath = "extensions.ai-agents.project.context" + +// EndpointSource identifies where the resolved project endpoint came from. +type EndpointSource string + +const ( + // SourceFlag means the endpoint came from a --project-endpoint flag (level 1). + SourceFlag EndpointSource = "flag" + // SourceAzdEnv means the endpoint came from the active azd environment's + // AZURE_AI_PROJECT_ENDPOINT value (level 2). + SourceAzdEnv EndpointSource = "azdEnv" + // SourceGlobalConfig means the endpoint came from ~/.azd/config.json + // (extensions.ai-projects.context.endpoint) (level 3). + SourceGlobalConfig EndpointSource = "globalConfig" + // SourceFoundryEnv means the endpoint came from the FOUNDRY_PROJECT_ENDPOINT + // host environment variable (level 4). + SourceFoundryEnv EndpointSource = "foundryEnv" +) + +// foundryHostSuffixes is the authoritative list of accepted Foundry host suffixes. +var foundryHostSuffixes = []string{ + ".services.ai.azure.com", +} + +// projectEndpointPathPrefix is the expected path prefix for Foundry project endpoints. +const projectEndpointPathPrefix = "/api/projects/" + +// isFoundryHost reports whether the hostname ends with one of the recognized +// Foundry host suffixes. +func isFoundryHost(hostname string) bool { + h := strings.ToLower(hostname) + for _, suffix := range foundryHostSuffixes { + if strings.HasSuffix(h, suffix) { + return true + } + } + return false +} + +// validateProjectEndpoint validates and normalizes a Foundry project endpoint URL. +// +// The URL must be an absolute https:// URL whose host ends with a recognized +// Foundry suffix (see [foundryHostSuffixes]). Whitespace is trimmed, trailing +// slashes are stripped, and the result is returned in normalized form. +// +// The second return value is true when the path does not look like +// /api/projects/ — callers may use this as a non-fatal warning. +func validateProjectEndpoint(raw string) (normalized string, pathWarning bool, err error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return "", false, exterrors.Validation( + exterrors.CodeInvalidParameter, + "project endpoint must not be empty", + "provide a Foundry project endpoint URL "+ + "(e.g. https://.services.ai.azure.com/api/projects/)", + ) + } + + u, parseErr := url.Parse(raw) + if parseErr != nil { + return "", false, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("invalid project endpoint URL: %v", parseErr), + "provide a valid https:// Foundry project endpoint URL", + ) + } + + if !strings.EqualFold(u.Scheme, "https") { + return "", false, exterrors.Validation( + exterrors.CodeInvalidParameter, + "project endpoint must use https", + "provide an https:// URL", + ) + } + + host := u.Hostname() + if host == "" || !isFoundryHost(host) { + return "", false, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf( + "project endpoint host %q is not a recognized Foundry host (*%s)", + host, foundryHostSuffixes[0], + ), + "the host must end with "+foundryHostSuffixes[0], + ) + } + + if u.Port() != "" { + return "", false, exterrors.Validation( + exterrors.CodeInvalidParameter, + fmt.Sprintf("project endpoint host %q must not include a port", u.Host), + "remove the explicit port from the URL", + ) + } + + // Normalize: lowercase host, strip trailing slash. + path := strings.TrimRight(u.EscapedPath(), "/") + normalized = fmt.Sprintf("https://%s%s", strings.ToLower(host), path) + + // Warn when the path does not look like /api/projects/. + if !strings.HasPrefix(path, projectEndpointPathPrefix) || + strings.TrimPrefix(path, projectEndpointPathPrefix) == "" { + pathWarning = true + } + + return normalized, pathWarning, nil +} + +// noProjectEndpointError returns the structured dependency error used when no +// project endpoint could be resolved from any source. +func noProjectEndpointError() error { + return exterrors.Dependency( + exterrors.CodeMissingProjectEndpoint, + "no Foundry project endpoint resolved", + "persist a workspace default with `azd ai project set `, "+ + "or set AZURE_AI_PROJECT_ENDPOINT in the active azd environment, "+ + "or export FOUNDRY_PROJECT_ENDPOINT in your shell", + ) +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint_test.go new file mode 100644 index 00000000000..de1ef6777d7 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_endpoint_test.go @@ -0,0 +1,108 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "testing" + + "azure.ai.projects/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestValidateProjectEndpoint_ValidURLs(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + want string + wantWarning bool + }{ + { + name: "canonical URL", + input: "https://my-acct.services.ai.azure.com/api/projects/my-proj", + want: "https://my-acct.services.ai.azure.com/api/projects/my-proj", + }, + { + name: "trailing slash stripped", + input: "https://my-acct.services.ai.azure.com/api/projects/my-proj/", + want: "https://my-acct.services.ai.azure.com/api/projects/my-proj", + }, + { + name: "whitespace trimmed", + input: " https://my-acct.services.ai.azure.com/api/projects/my-proj ", + want: "https://my-acct.services.ai.azure.com/api/projects/my-proj", + }, + { + name: "uppercase host normalized", + input: "https://MY-ACCT.SERVICES.AI.AZURE.COM/api/projects/my-proj", + want: "https://my-acct.services.ai.azure.com/api/projects/my-proj", + }, + { + name: "missing /api/projects path warns", + input: "https://my-acct.services.ai.azure.com", + want: "https://my-acct.services.ai.azure.com", + wantWarning: true, + }, + { + name: "partial path warns", + input: "https://my-acct.services.ai.azure.com/api/projects/", + want: "https://my-acct.services.ai.azure.com/api/projects", + wantWarning: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got, warn, err := validateProjectEndpoint(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantWarning, warn) + }) + } +} + +func TestValidateProjectEndpoint_Rejections(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + }{ + {"empty", ""}, + {"whitespace only", " "}, + {"http scheme", "http://my-acct.services.ai.azure.com/api/projects/p"}, + {"non-foundry host", "https://example.com/api/projects/p"}, + {"explicit port", "https://my-acct.services.ai.azure.com:8080/api/projects/p"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + _, _, err := validateProjectEndpoint(tt.input) + require.Error(t, err) + var localErr *azdext.LocalError + assert.ErrorAs(t, err, &localErr) + }) + } +} + +func TestIsFoundryHost(t *testing.T) { + t.Parallel() + assert.True(t, isFoundryHost("my-acct.services.ai.azure.com")) + assert.True(t, isFoundryHost("MY-ACCT.SERVICES.AI.AZURE.COM")) + assert.False(t, isFoundryHost("example.com")) + assert.False(t, isFoundryHost("")) +} + +func TestNoProjectEndpointError(t *testing.T) { + t.Parallel() + err := noProjectEndpointError() + require.Error(t, err) + + var localErr *azdext.LocalError + require.ErrorAs(t, err, &localErr) + assert.Equal(t, exterrors.CodeMissingProjectEndpoint, localErr.Code) + assert.Equal(t, azdext.LocalErrorCategoryDependency, localErr.Category) +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go new file mode 100644 index 00000000000..130750a6e7b --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver.go @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "os" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// resolveProjectEndpointOpts controls the 5-level endpoint resolution cascade. +type resolveProjectEndpointOpts struct { + // FlagValue is the value of an explicit endpoint flag (level 1). + // Empty means the flag was not provided. + FlagValue string + + // ReadAzdHostedSources lets callers inject a stub for the azd-hosted + // lookup (levels 2 + 3). Production callers leave this nil; the resolver + // then uses the real [readAzdHostedSources]. + ReadAzdHostedSources func(context.Context) (azdHostedSources, error) +} + +// resolvedEndpoint holds the result of resolveProjectEndpoint. +type resolvedEndpoint struct { + Endpoint string + Source EndpointSource + AzdEnvName string + SetAt string // RFC3339 timestamp, only meaningful when Source == SourceGlobalConfig + // FromLegacyAgentsConfig is true when Source == SourceGlobalConfig and the + // value was read from the legacy `extensions.ai-agents.project.context` + // key. The resolver migrates the value to the new key in the same call, + // so this flag is only true on the first run that observes the legacy + // key — callers can use it to surface a one-time confirmation notice. + FromLegacyAgentsConfig bool +} + +// 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 +// resolveProjectEndpointOpts.ReadAzdHostedSources. +type azdHostedSources struct { + // EnvValue is the AZURE_AI_PROJECT_ENDPOINT value from the active azd + // env, or "" if not set / no active env / no azd client available. + 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 + // CfgFromLegacyAgents reports whether CfgState was sourced from the + // legacy `extensions.ai-agents.project.context` key. Only meaningful + // when CfgFound is true. + CfgFromLegacyAgents bool +} + +// 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 + } + + // Legacy fallback: read the key written by the removed + // `azd ai agent project set` command. Errors are swallowed (best-effort) + // so a malformed legacy blob does not block FOUNDRY_PROJECT_ENDPOINT. + if !out.CfgFound { + if legacyState, legacyFound := getLegacyAgentsProjectContext(ctx, azdClient); legacyFound { + out.CfgState = legacyState + out.CfgFound = true + out.CfgFromLegacyAgents = true + + // Auto-migrate the legacy value into the new key so subsequent + // invocations resolve from `extensions.ai-projects.context` + // directly. Best-effort: failures are intentionally swallowed + // so a transient config write does not break resolution. + _ = migrateLegacyAgentsProjectContext(ctx, azdClient, legacyState) + } + } + + return out, nil +} + +// containsGRPCCode walks the error chain looking for a gRPC status with the +// specified code. Because fmt.Errorf("%w", ...) wraps errors without forwarding +// the GRPCStatus() method, we must unwrap manually. +// Note: only follows errors.Unwrap chains; errors.Join multi-wraps are not traversed. +func containsGRPCCode(err error, code codes.Code) bool { + for ; err != nil; err = errors.Unwrap(err) { + if st, ok := status.FromError(err); ok && st.Code() == code { + return true + } + } + return false +} + +// resolveProjectEndpoint resolves a Foundry project endpoint using the 5-level +// cascade defined in the design spec: +// +// 1. Explicit flag (--project-endpoint, if exposed by the caller) +// 2. Active azd env value (AZURE_AI_PROJECT_ENDPOINT) +// 3. Global config: extensions.ai-projects.context.endpoint in ~/.azd/config.json +// 4. Host environment variable FOUNDRY_PROJECT_ENDPOINT +// 5. Structured error with actionable suggestion +// +// Invalid values at any level produce a hard validation error (no silent fallback). +func resolveProjectEndpoint( + ctx context.Context, + opts resolveProjectEndpointOpts, +) (*resolvedEndpoint, error) { + // Level 1: explicit flag. + if opts.FlagValue != "" { + normalized, _, err := validateProjectEndpoint(opts.FlagValue) + if err != nil { + return nil, err + } + return &resolvedEndpoint{ + Endpoint: normalized, + Source: SourceFlag, + }, nil + } + + // Levels 2 + 3: azd-hosted sources (active env, then global config). + readSources := opts.ReadAzdHostedSources + if readSources == nil { + readSources = readAzdHostedSources + } + sources, err := readSources(ctx) + if err != nil { + return nil, err + } + + // Level 2: active azd environment's AZURE_AI_PROJECT_ENDPOINT. + if sources.EnvValue != "" { + normalized, _, err := validateProjectEndpoint(sources.EnvValue) + if err != nil { + return nil, err + } + return &resolvedEndpoint{ + Endpoint: normalized, + Source: SourceAzdEnv, + AzdEnvName: sources.EnvName, + }, nil + } + + // Level 3: global config (~/.azd/config.json). + if sources.CfgFound && sources.CfgState.Endpoint != "" { + normalized, _, err := validateProjectEndpoint(sources.CfgState.Endpoint) + if err != nil { + return nil, err + } + return &resolvedEndpoint{ + Endpoint: normalized, + Source: SourceGlobalConfig, + SetAt: sources.CfgState.SetAt, + FromLegacyAgentsConfig: sources.CfgFromLegacyAgents, + }, nil + } + + // Level 4: host environment variable FOUNDRY_PROJECT_ENDPOINT. + if envVal := os.Getenv("FOUNDRY_PROJECT_ENDPOINT"); envVal != "" { + normalized, _, err := validateProjectEndpoint(envVal) + if err != nil { + return nil, err + } + return &resolvedEndpoint{ + Endpoint: normalized, + Source: SourceFoundryEnv, + }, nil + } + + // Level 5: structured error. + return nil, noProjectEndpointError() +} diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver_test.go new file mode 100644 index 00000000000..f4ec96eb950 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_resolver_test.go @@ -0,0 +1,227 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "testing" + + "azure.ai.projects/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubReadAzdHostedSources returns a function suitable for the +// resolveProjectEndpointOpts.ReadAzdHostedSources seam. Each call returns a +// fresh closure so no test mutates state shared with other tests. +func stubReadAzdHostedSources( + sources azdHostedSources, + err error, +) func(context.Context) (azdHostedSources, error) { + return func(context.Context) (azdHostedSources, error) { + return sources, err + } +} + +// isolateFromAzdDaemon returns resolver opts whose ReadAzdHostedSources +// reports no hosted sources, and clears AZD_SERVER so any code path that +// bypasses the stub cannot reach a real daemon. The resolver under test then +// only sees the flag and FOUNDRY_PROJECT_ENDPOINT. +func isolateFromAzdDaemon(t *testing.T) resolveProjectEndpointOpts { + t.Helper() + t.Setenv("AZD_SERVER", "") + return resolveProjectEndpointOpts{ + ReadAzdHostedSources: stubReadAzdHostedSources(azdHostedSources{}, nil), + } +} + +func TestResolveProjectEndpoint_FlagWins(t *testing.T) { + // Even with FOUNDRY_PROJECT_ENDPOINT and azd-hosted sources set, the flag should win. + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://env.services.ai.azure.com/api/projects/env-proj") + + result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + FlagValue: "https://flag.services.ai.azure.com/api/projects/flag-proj", + ReadAzdHostedSources: stubReadAzdHostedSources(azdHostedSources{ + EnvValue: "https://azdenv.services.ai.azure.com/api/projects/p", + EnvName: "dev", + }, nil), + }) + require.NoError(t, err) + assert.Equal(t, "https://flag.services.ai.azure.com/api/projects/flag-proj", result.Endpoint) + 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") + + result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + ReadAzdHostedSources: stubReadAzdHostedSources(azdHostedSources{ + EnvValue: " HTTPS://Azdenv.Services.AI.Azure.com/api/projects/p/ ", + EnvName: "dev", + CfgState: projectContextState{ + Endpoint: "https://cfg.services.ai.azure.com/api/projects/p", + SetAt: "2025-01-01T00:00:00Z", + }, + CfgFound: true, + }, nil), + }) + require.NoError(t, err) + assert.Equal(t, "https://azdenv.services.ai.azure.com/api/projects/p", result.Endpoint) + assert.Equal(t, SourceAzdEnv, result.Source) + 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") + + _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + ReadAzdHostedSources: stubReadAzdHostedSources(azdHostedSources{ + EnvValue: "http://not-https.services.ai.azure.com/api/projects/p", + EnvName: "dev", + }, nil), + }) + require.Error(t, err) + + var localErr *azdext.LocalError + 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") + + result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + ReadAzdHostedSources: stubReadAzdHostedSources(azdHostedSources{ + CfgState: projectContextState{ + Endpoint: " HTTPS://Cfg.Services.AI.Azure.com/api/projects/p/ ", + SetAt: "2025-01-02T03:04:05Z", + }, + CfgFound: true, + }, nil), + }) + require.NoError(t, err) + assert.Equal(t, "https://cfg.services.ai.azure.com/api/projects/p", result.Endpoint) + assert.Equal(t, SourceGlobalConfig, result.Source) + 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") + + _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + ReadAzdHostedSources: stubReadAzdHostedSources(azdHostedSources{ + CfgState: projectContextState{ + Endpoint: "http://not-https.services.ai.azure.com/api/projects/p", + SetAt: "2025-01-02T03:04:05Z", + }, + CfgFound: true, + }, nil), + }) + require.Error(t, err) + + var localErr *azdext.LocalError + 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") + + _, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + ReadAzdHostedSources: stubReadAzdHostedSources(azdHostedSources{}, sentinel), + }) + require.ErrorIs(t, err, sentinel) +} + +func TestResolveProjectEndpoint_FoundryEnvFallback(t *testing.T) { + // No flag, no azd-hosted sources → falls back to FOUNDRY_PROJECT_ENDPOINT. + opts := isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://env.services.ai.azure.com/api/projects/env-proj") + + result, err := resolveProjectEndpoint(t.Context(), opts) + require.NoError(t, err) + assert.Equal(t, "https://env.services.ai.azure.com/api/projects/env-proj", result.Endpoint) + assert.Equal(t, SourceFoundryEnv, result.Source) +} + +func TestResolveProjectEndpoint_NothingResolvable(t *testing.T) { + opts := isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "") + + _, err := resolveProjectEndpoint(t.Context(), opts) + 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) { + opts := isolateFromAzdDaemon(t) + opts.FlagValue = "http://not-https.services.ai.azure.com/api/projects/p" + _, err := resolveProjectEndpoint(t.Context(), opts) + require.Error(t, err) + + var localErr *azdext.LocalError + require.ErrorAs(t, err, &localErr) + assert.Contains(t, localErr.Message, "https") +} + +func TestResolveProjectEndpoint_InvalidFoundryEnvRejected(t *testing.T) { + opts := isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "http://bad.services.ai.azure.com/api/projects/p") + + _, err := resolveProjectEndpoint(t.Context(), opts) + 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) { + opts := isolateFromAzdDaemon(t) + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", " https://X.SERVICES.AI.AZURE.COM/api/projects/p/ ") + + result, err := resolveProjectEndpoint(t.Context(), opts) + require.NoError(t, err) + assert.Equal(t, "https://x.services.ai.azure.com/api/projects/p", result.Endpoint) + assert.Equal(t, SourceFoundryEnv, result.Source) +} + +func TestResolveProjectEndpoint_LegacyAgentsConfigResolves(t *testing.T) { + // Level 3 with the new key absent, but the legacy + // `extensions.ai-agents.project.context` key present. The resolver should + // surface the legacy value as SourceGlobalConfig and flag the result so + // callers can prompt the user to migrate. + t.Setenv("FOUNDRY_PROJECT_ENDPOINT", "https://foundry.services.ai.azure.com/api/projects/p") + + result, err := resolveProjectEndpoint(t.Context(), resolveProjectEndpointOpts{ + ReadAzdHostedSources: stubReadAzdHostedSources(azdHostedSources{ + CfgState: projectContextState{ + Endpoint: "https://legacy.services.ai.azure.com/api/projects/p", + SetAt: "2024-12-31T23:59:59Z", + }, + CfgFound: true, + CfgFromLegacyAgents: true, + }, nil), + }) + require.NoError(t, err) + assert.Equal(t, "https://legacy.services.ai.azure.com/api/projects/p", result.Endpoint) + assert.Equal(t, SourceGlobalConfig, result.Source) + assert.True(t, result.FromLegacyAgentsConfig, + "resolver must propagate the legacy flag so show can surface a migration notice") +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_set.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_set.go similarity index 84% rename from cli/azd/extensions/azure.ai.agents/internal/cmd/project_set.go rename to cli/azd/extensions/azure.ai.projects/internal/cmd/project_set.go index ac549479d83..b3de71411ec 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_set.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_set.go @@ -9,6 +9,8 @@ import ( "fmt" "os" + "azure.ai.projects/internal/exterrors" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" ) @@ -26,7 +28,7 @@ type projectSetResult struct { SetAt string `json:"setAt"` } -// ProjectSetAction is the action for the `project set` command. +// ProjectSetAction is the action for the `set` command. type ProjectSetAction struct { flags *projectSetFlags } @@ -39,10 +41,10 @@ func newProjectSetCommand(extCtx *azdext.ExtensionContext) *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/config.json). Other commands will resolve this endpoint when no azd environment or explicit flag is available.`, Example: ` # Set the default project endpoint - azd ai agent project set https://my-project.services.ai.azure.com/api/projects/my-project`, + azd ai project set https://my-project.services.ai.azure.com/api/projects/my-project`, Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { flags.endpoint = args[0] @@ -72,7 +74,13 @@ func (a *ProjectSetAction) Run(ctx context.Context) error { azdClient, err := azdext.NewAzdClient() if err != nil { - return fmt.Errorf("failed to create azd client: %w", err) + return exterrors.Dependency( + exterrors.CodeAzdClientFailed, + "could not connect to the azd daemon", + "ensure azd is installed and reachable; "+ + "if you are running this command outside an azd extension host, "+ + "the daemon endpoint may not be configured", + ) } defer azdClient.Close() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_set_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_set_test.go similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/cmd/project_set_test.go rename to cli/azd/extensions/azure.ai.projects/internal/cmd/project_set_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go similarity index 70% rename from cli/azd/extensions/azure.ai.agents/internal/cmd/project_show.go rename to cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go index c97ca71cd41..258e8ab1ac2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show.go @@ -6,13 +6,10 @@ package cmd import ( "context" "encoding/json" - "errors" "fmt" "os" "text/tabwriter" - "azureaiagent/internal/exterrors" - "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" ) @@ -27,9 +24,14 @@ type projectShowResult struct { SourceDetail string `json:"sourceDetail"` AzdEnv string `json:"azdEnv"` SetAt string `json:"setAt,omitempty"` + // FromLegacyAgentsConfig mirrors [resolvedEndpoint.FromLegacyAgentsConfig]. + // True only on the run that migrated the legacy + // `extensions.ai-agents.project.context` value into the new key, so + // automation can detect the one-time migration notice without parsing stderr. + FromLegacyAgentsConfig bool `json:"fromLegacyAgentsConfig,omitempty"` } -// ProjectShowAction is the action for the `project show` command. +// ProjectShowAction is the action for the `show` command. type ProjectShowAction struct { flags *projectShowFlags } @@ -42,9 +44,9 @@ func newProjectShowCommand(extCtx *azdext.ExtensionContext) *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.`, +that provided it. Useful for debugging which endpoint commands will use.`, Example: ` # Show the resolved endpoint - azd ai agent project show`, + azd ai project show`, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { flags.outputFmt = extCtx.OutputFormat @@ -67,15 +69,8 @@ that provided it. Useful for debugging which endpoint agent commands will use.`, func (a *ProjectShowAction) Run(ctx context.Context) error { result, err := resolveProjectEndpoint(ctx, resolveProjectEndpointOpts{}) if err != nil { - // Re-wrap missing-endpoint errors to surface `project set` as the fix. - if localErr, ok := errors.AsType[*azdext.LocalError](err); ok && - localErr.Code == exterrors.CodeMissingProjectEndpoint { - return exterrors.Dependency( - exterrors.CodeMissingProjectEndpoint, - localErr.Message, - "run `azd ai agent project set ` to persist a default, or "+localErr.Suggestion, - ) - } + // noProjectEndpointError already suggests `azd ai project set`, so + // the structured error is actionable for `show` unchanged. return err } @@ -84,11 +79,12 @@ func (a *ProjectShowAction) Run(ctx context.Context) error { switch a.flags.outputFmt { case "json": out := projectShowResult{ - Endpoint: result.Endpoint, - Source: string(result.Source), - SourceDetail: jsonSourceDetail(result.Source), - AzdEnv: result.AzdEnvName, - SetAt: result.SetAt, + Endpoint: result.Endpoint, + Source: string(result.Source), + SourceDetail: jsonSourceDetail(result.Source), + AzdEnv: result.AzdEnvName, + SetAt: result.SetAt, + FromLegacyAgentsConfig: result.FromLegacyAgentsConfig, } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") @@ -100,7 +96,17 @@ func (a *ProjectShowAction) Run(ctx context.Context) error { if result.Source == SourceGlobalConfig && result.SetAt != "" { fmt.Fprintf(w, "Set at:\t%s\n", result.SetAt) } - return w.Flush() + if err := w.Flush(); err != nil { + return err + } + if result.FromLegacyAgentsConfig { + fmt.Fprintln(os.Stderr, + "notice: migrated this endpoint from the legacy "+ + "`extensions.ai-agents.project.context` key to the new "+ + "`extensions.ai-projects.context` key. Future commands "+ + "will read from the new key directly.") + } + return nil } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show_test.go similarity index 84% rename from cli/azd/extensions/azure.ai.agents/internal/cmd/project_show_test.go rename to cli/azd/extensions/azure.ai.projects/internal/cmd/project_show_test.go index 67018e7c46c..54ed3fb3646 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_show_test.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_show_test.go @@ -57,16 +57,16 @@ func TestHumanSourceDetail(t *testing.T) { } } -func TestProjectCommand_HasSubcommands(t *testing.T) { +func TestRootCommand_HasProjectSubcommands(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()) + root := NewRootCommand() + names := make(map[string]bool, len(root.Commands())) + for _, sub := range root.Commands() { + names[sub.Name()] = true } - assert.Contains(t, names, "set") - assert.Contains(t, names, "unset") - assert.Contains(t, names, "show") + assert.True(t, names["set"], "root should have `set` subcommand") + assert.True(t, names["unset"], "root should have `unset` subcommand") + assert.True(t, names["show"], "root should have `show` subcommand") } func TestJSONSourceDetail(t *testing.T) { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_unset.go similarity index 71% rename from cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset.go rename to cli/azd/extensions/azure.ai.projects/internal/cmd/project_unset.go index 50b6e9bfffd..c4c393f1c17 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_unset.go @@ -9,6 +9,8 @@ import ( "fmt" "os" + "azure.ai.projects/internal/exterrors" + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/spf13/cobra" ) @@ -18,11 +20,13 @@ type projectUnsetFlags struct { } type projectUnsetResult struct { - Cleared bool `json:"cleared"` + // PreviouslySet reports whether a project endpoint was set before this + // unset operation. The operation succeeds (idempotently) regardless. + PreviouslySet bool `json:"previouslySet"` PreviousEndpoint string `json:"previousEndpoint"` } -// ProjectUnsetAction is the action for the `project unset` command. +// ProjectUnsetAction is the action for the `unset` command. type ProjectUnsetAction struct { flags *projectUnsetFlags } @@ -59,7 +63,13 @@ is not an error.`, func (a *ProjectUnsetAction) Run(ctx context.Context) error { azdClient, err := azdext.NewAzdClient() if err != nil { - return fmt.Errorf("failed to create azd client: %w", err) + return exterrors.Dependency( + exterrors.CodeAzdClientFailed, + "could not connect to the azd daemon", + "ensure azd is installed and reachable; "+ + "if you are running this command outside an azd extension host, "+ + "the daemon endpoint may not be configured", + ) } defer azdClient.Close() @@ -68,22 +78,22 @@ func (a *ProjectUnsetAction) Run(ctx context.Context) error { return err } - cleared := previous != "" + previouslySet := previous != "" switch a.flags.outputFmt { case "json": result := projectUnsetResult{ - Cleared: cleared, + PreviouslySet: previouslySet, PreviousEndpoint: previous, } enc := json.NewEncoder(os.Stdout) enc.SetIndent("", " ") return enc.Encode(result) default: - if !cleared { - fmt.Println("No active project endpoint to clear.") - } else { + if previouslySet { fmt.Println("Project endpoint cleared.") + } else { + fmt.Println("No project endpoint was set; nothing to clear.") } return nil } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset_test.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/project_unset_test.go similarity index 100% rename from cli/azd/extensions/azure.ai.agents/internal/cmd/project_unset_test.go rename to cli/azd/extensions/azure.ai.projects/internal/cmd/project_unset_test.go diff --git a/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go b/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go index c49db93b7e1..faa218b61c0 100644 --- a/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go +++ b/cli/azd/extensions/azure.ai.projects/internal/cmd/root.go @@ -26,6 +26,9 @@ func NewRootCommand() *cobra.Command { rootCmd.AddCommand(newContextCommand()) rootCmd.AddCommand(newVersionCommand(&extCtx.OutputFormat)) rootCmd.AddCommand(newMetadataCommand(rootCmd)) + rootCmd.AddCommand(newProjectSetCommand(extCtx)) + rootCmd.AddCommand(newProjectUnsetCommand(extCtx)) + rootCmd.AddCommand(newProjectShowCommand(extCtx)) return rootCmd } diff --git a/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go new file mode 100644 index 00000000000..58f4399963c --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/exterrors/codes.go @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package exterrors + +// Error codes commonly used for validation errors. +// +// These are paired with [Validation] when user input or configuration values +// fail validation. +const ( + CodeInvalidParameter = "invalid_parameter" +) + +// Error codes commonly used for dependency errors. +// +// These are paired with [Dependency] when a required external value is missing. +const ( + CodeMissingProjectEndpoint = "missing_project_endpoint" + CodeAzdClientFailed = "azd_client_failed" +) diff --git a/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go b/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go new file mode 100644 index 00000000000..c230dd8ec15 --- /dev/null +++ b/cli/azd/extensions/azure.ai.projects/internal/exterrors/errors.go @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package exterrors provides structured error helpers for the azure.ai.projects extension. +// +// This package mirrors a subset of azure.ai.agents/internal/exterrors so the +// two extensions can be consolidated into a shared package in a follow-up. +// +// Use plain Go errors until the current code can confidently choose a final +// category, code, and suggestion. At that point, create a structured error +// with one of the helpers in this package. +// +// Once an error is structured, return it unchanged. Avoid wrapping a structured +// error with [fmt.Errorf] and %w for extra context: azd serializes the +// structured error's own message and metadata, not the outer wrapper text. +package exterrors + +import ( + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// Validation returns a validation [azdext.LocalError] for user-input or +// configuration errors. +func Validation(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryValidation, + Suggestion: suggestion, + } +} + +// Dependency returns a dependency [azdext.LocalError] for missing resources or +// services. +func Dependency(code, message, suggestion string) error { + return &azdext.LocalError{ + Message: message, + Code: code, + Category: azdext.LocalErrorCategoryDependency, + Suggestion: suggestion, + } +}