|
| 1 | +# Extension Flag Handling Design |
| 2 | + |
| 3 | +> **Status**: Design summary of current implementation (not a prescriptive spec). |
| 4 | +> Extracted from the codebase as of March 2026 to document the existing behavior and |
| 5 | +> call out undefined or ambiguous scenarios. |
| 6 | +
|
| 7 | +## Overview |
| 8 | + |
| 9 | +When a user runs an extension command (e.g. `azd myext --my-flag value -e dev`), flags |
| 10 | +must be split between two consumers: |
| 11 | + |
| 12 | +| Consumer | Needs | Examples | |
| 13 | +|----------|-------|---------| |
| 14 | +| **azd host** | Global flags that control host behavior | `--debug`, `--cwd`, `-e`, `--no-prompt` | |
| 15 | +| **Extension process** | Extension-specific flags + positional args | `--my-flag value`, `--model gpt-4` | |
| 16 | + |
| 17 | +Today there is **no explicit separator** (like `--`) between the two. Instead, azd uses |
| 18 | +a combination of early pre-parsing and Cobra's `DisableFlagParsing` to handle this. |
| 19 | + |
| 20 | +## Current Implementation |
| 21 | + |
| 22 | +### Phase 1: Early Pre-Parsing (`ParseGlobalFlags`) |
| 23 | + |
| 24 | +**File**: `cmd/auto_install.go` — `ParseGlobalFlags()` |
| 25 | + |
| 26 | +Before Cobra builds the command tree, `ParseGlobalFlags()` extracts known azd global |
| 27 | +flags from the raw `os.Args` using a pflag FlagSet with `ParseErrorsAllowlist{UnknownFlags: true}`. |
| 28 | +Unknown flags (extension flags) are silently ignored. |
| 29 | + |
| 30 | +**Flags parsed in this phase:** |
| 31 | + |
| 32 | +| Flag | Short | Stored in `GlobalCommandOptions` | |
| 33 | +|------|-------|----------------------------------| |
| 34 | +| `--cwd` | `-C` | `Cwd` | |
| 35 | +| `--debug` | — | `EnableDebugLogging` | |
| 36 | +| `--no-prompt` | — | `NoPrompt` | |
| 37 | +| `--trace-log-file` | — | *(read by telemetry system)* | |
| 38 | +| `--trace-log-url` | — | *(read by telemetry system)* | |
| 39 | + |
| 40 | +**Not parsed here**: `-e` / `--environment` is currently absent from `CreateGlobalFlagSet()`. |
| 41 | +It is registered as a per-command flag on commands that need it (via `envFlag` binding), |
| 42 | +not as a global flag. This is the root cause of [#7034](https://github.com/Azure/azure-dev/issues/7034). |
| 43 | + |
| 44 | +The result is stored in a `GlobalCommandOptions` singleton registered in the IoC container. |
| 45 | + |
| 46 | +**Agent detection**: If `--no-prompt` was not explicitly set, `ParseGlobalFlags` also |
| 47 | +checks `agentdetect.IsRunningInAgent()` and auto-enables `NoPrompt` for AI coding agents. |
| 48 | + |
| 49 | +### Phase 2: Cobra Command Registration |
| 50 | + |
| 51 | +**File**: `cmd/extensions.go` — `bindExtension()` |
| 52 | + |
| 53 | +Extension commands are registered with: |
| 54 | + |
| 55 | +```go |
| 56 | +cmd := &cobra.Command{ |
| 57 | + Use: lastPart, |
| 58 | + DisableFlagParsing: true, |
| 59 | +} |
| 60 | +``` |
| 61 | + |
| 62 | +`DisableFlagParsing: true` means Cobra will **not** parse any flags for these commands. |
| 63 | +All tokens after the command name are passed to the action handler as raw `args`. |
| 64 | + |
| 65 | +**Consequence**: Cobra's persistent flags (including `-e`/`--environment` if registered) |
| 66 | +are not parsed for extension commands. Calls to `cmd.Flags().GetString("environment")` |
| 67 | +silently return `""`. |
| 68 | + |
| 69 | +### Phase 3: Extension Action Execution |
| 70 | + |
| 71 | +**File**: `cmd/extensions.go` — `extensionAction.Run()` |
| 72 | + |
| 73 | +The action handler builds `InvokeOptions` from two sources: |
| 74 | + |
| 75 | +1. **Cobra persistent flags** (`cmd.Flags().Get*`) — for `debug`, `cwd`, `environment` |
| 76 | +2. **`GlobalCommandOptions`** — for `NoPrompt` (because it includes agent detection) |
| 77 | + |
| 78 | +```go |
| 79 | +debugEnabled, _ := a.cmd.Flags().GetBool("debug") |
| 80 | +cwd, _ := a.cmd.Flags().GetString("cwd") |
| 81 | +envName, _ := a.cmd.Flags().GetString("environment") |
| 82 | + |
| 83 | +options := &extensions.InvokeOptions{ |
| 84 | + Args: a.args, // ALL raw args (extension + global flags) |
| 85 | + Debug: debugEnabled, |
| 86 | + NoPrompt: a.globalOptions.NoPrompt, |
| 87 | + Cwd: cwd, |
| 88 | + Environment: envName, |
| 89 | +} |
| 90 | +``` |
| 91 | + |
| 92 | +**Problem**: Because `DisableFlagParsing: true`, `cmd.Flags().Get*` calls for `debug`, |
| 93 | +`cwd`, and `environment` return zero values. Only `NoPrompt` works correctly because it |
| 94 | +reads from `globalOptions` (populated in Phase 1). |
| 95 | + |
| 96 | +### Phase 4: Environment Variable Propagation |
| 97 | + |
| 98 | +**File**: `pkg/extensions/runner.go` — `Runner.Invoke()` |
| 99 | + |
| 100 | +Global flag values from `InvokeOptions` are converted to environment variables before |
| 101 | +spawning the extension process: |
| 102 | + |
| 103 | +| `InvokeOptions` field | Environment variable | Condition | |
| 104 | +|-----------------------|---------------------|-----------| |
| 105 | +| `Debug` | `AZD_DEBUG=true` | if true | |
| 106 | +| `NoPrompt` | `AZD_NO_PROMPT=true` | if true | |
| 107 | +| `Cwd` | `AZD_CWD=<value>` | if non-empty | |
| 108 | +| `Environment` | `AZD_ENVIRONMENT=<value>` | if non-empty | |
| 109 | + |
| 110 | +The extension process also receives: |
| 111 | +- `AZD_SERVER=<host>:<port>` — gRPC server address |
| 112 | +- `AZD_ACCESS_TOKEN=<jwt>` — Authentication token |
| 113 | +- `TRACEPARENT=<value>` — W3C trace context (if tracing is active) |
| 114 | +- Environment variables from the resolved azd environment (`env.Environ()`) |
| 115 | + |
| 116 | +### Phase 5: Middleware Extensions (Listener Path) |
| 117 | + |
| 118 | +**File**: `cmd/middleware/extensions.go` — `ExtensionsMiddleware.Run()` |
| 119 | + |
| 120 | +Lifecycle extensions (those with `lifecycle-events`, `service-target-provider`, or |
| 121 | +`framework-service-provider` capabilities) use a different execution path: |
| 122 | + |
| 123 | +- Started with fixed args `["listen"]` |
| 124 | +- Read global flags from `m.options.Flags` (Cobra persistent flags from the *parent* command) |
| 125 | +- Also have access to `m.globalOptions` |
| 126 | + |
| 127 | +This path works for standard azd commands (like `azd deploy`) where Cobra *does* parse |
| 128 | +persistent flags. The middleware invocation constructs `InvokeOptions` similarly. |
| 129 | + |
| 130 | +## What Extensions Receive |
| 131 | + |
| 132 | +When a user runs: `azd myext --my-flag value -e dev --debug` |
| 133 | + |
| 134 | +The extension process gets: |
| 135 | + |
| 136 | +| Channel | Content | |
| 137 | +|---------|---------| |
| 138 | +| **Command-line args** | `["--my-flag", "value", "-e", "dev", "--debug"]` — ALL tokens after the command name, including azd global flags | |
| 139 | +| **Environment variables** | `AZD_DEBUG`, `AZD_NO_PROMPT`, `AZD_CWD`, `AZD_ENVIRONMENT` — set only if their `InvokeOptions` field is non-empty | |
| 140 | +| **gRPC services** | Extension can call `EnvironmentService.GetCurrentEnvironment()` to get the resolved environment | |
| 141 | + |
| 142 | +**Key observation**: Global azd flags appear in **both** the raw args and environment |
| 143 | +variables. Extensions must decide which source to trust. |
| 144 | + |
| 145 | +## Extension-Side Flag Parsing |
| 146 | + |
| 147 | +Extensions are expected to: |
| 148 | + |
| 149 | +1. **Read global azd flags from environment variables** (`AZD_DEBUG`, `AZD_NO_PROMPT`, |
| 150 | + `AZD_CWD`, `AZD_ENVIRONMENT`), not from their command-line args. |
| 151 | +2. **Parse their own flags** from the command-line args they receive. The azd SDK provides |
| 152 | + helpers (`azdext` package) for this. |
| 153 | +3. **Ignore azd global flags in their args** — these are artifacts of `DisableFlagParsing` |
| 154 | + and should not be interpreted by the extension. |
| 155 | + |
| 156 | +However, this contract is **not formally documented or enforced**. Extensions using the |
| 157 | +Go SDK with Cobra will likely re-parse all args including the azd global flags. |
| 158 | + |
| 159 | +## Undefined Scenarios and Edge Cases |
| 160 | + |
| 161 | +### 1. Flag Name Collisions |
| 162 | + |
| 163 | +**Scenario**: Extension defines a flag with the same name as an azd global flag |
| 164 | +(e.g., `--debug` meaning "debug output format" in the extension). |
| 165 | + |
| 166 | +**Current behavior**: `ParseGlobalFlags` will consume `--debug` and set |
| 167 | +`GlobalCommandOptions.EnableDebugLogging = true`. The extension will also see `--debug` |
| 168 | +in its raw args and may interpret it differently. |
| 169 | + |
| 170 | +**Impact**: Both azd and the extension act on the same flag with different semantics. |
| 171 | +There is no way to say "this `--debug` is for the extension, not azd". |
| 172 | + |
| 173 | +### 2. Global Flags Not Stripped From Extension Args |
| 174 | + |
| 175 | +**Scenario**: User runs `azd myext -e dev --debug --my-flag value`. |
| 176 | + |
| 177 | +**Current behavior**: Extension receives args `["-e", "dev", "--debug", "--my-flag", "value"]`. |
| 178 | +The azd global flags are **not** removed from the args before passing to the extension. |
| 179 | + |
| 180 | +**Impact**: If the extension uses a strict flag parser, unknown flags like `--debug` may |
| 181 | +cause parse errors. Extensions must use permissive parsing or explicitly handle azd flags. |
| 182 | + |
| 183 | +### 3. No `--` Separator Convention |
| 184 | + |
| 185 | +**Scenario**: User wants to pass `--debug` to the extension but not to azd. |
| 186 | + |
| 187 | +**Current behavior**: No mechanism exists. `ParseGlobalFlags` will always consume |
| 188 | +recognized flags regardless of position. |
| 189 | + |
| 190 | +**Potential design**: A `--` separator could be introduced: |
| 191 | +`azd --debug myext -- --debug` (first `--debug` for azd, second for extension). |
| 192 | +This is not implemented. |
| 193 | + |
| 194 | +### 4. `-e` / `--environment` Not Pre-Parsed |
| 195 | + |
| 196 | +**Scenario**: User runs `azd myext -e dev`. |
| 197 | + |
| 198 | +**Current behavior** (main branch): `-e` is NOT in `CreateGlobalFlagSet()`, so |
| 199 | +`ParseGlobalFlags` ignores it. `cmd.Flags().GetString("environment")` returns `""` |
| 200 | +due to `DisableFlagParsing`. Result: `AZD_ENVIRONMENT` is never set, and `lazyEnv` |
| 201 | +resolves to the default environment. |
| 202 | + |
| 203 | +**Impact**: This is the bug described in [#7034](https://github.com/Azure/azure-dev/issues/7034). |
| 204 | +The default environment's variables leak into the extension process. |
| 205 | + |
| 206 | +### 5. Environment Variable Injection From Default Environment |
| 207 | + |
| 208 | +**Scenario**: Extension commands inject `lazyEnv.Environ()` (the resolved azd |
| 209 | +environment's key-value pairs) into the extension process's environment. |
| 210 | + |
| 211 | +**Question**: Should extensions receive environment variables from the azd environment |
| 212 | +at all, or should they exclusively use the gRPC `EnvironmentService`? |
| 213 | + |
| 214 | +**Current behavior**: `extensionAction.Run()` calls `a.lazyEnv.GetValue()` and appends |
| 215 | +all key-value pairs as process environment variables. This means the extension inherits |
| 216 | +every value from the resolved azd environment. |
| 217 | + |
| 218 | +**Trade-off**: Injecting env vars is convenient (extensions "just work" with standard |
| 219 | +`os.Getenv`), but it creates an implicit coupling and makes it hard to distinguish |
| 220 | +azd-injected variables from system environment variables. |
| 221 | + |
| 222 | +### 6. `--cwd` Semantics |
| 223 | + |
| 224 | +**Scenario**: User runs `azd myext -C ./subdir --my-flag value`. |
| 225 | + |
| 226 | +**Current behavior**: `ParseGlobalFlags` captures `Cwd = "./subdir"`. The root command |
| 227 | +changes the process working directory before the extension runs. `AZD_CWD=./subdir` is |
| 228 | +set in the extension's environment. |
| 229 | + |
| 230 | +**Question**: Should the extension receive the *original* cwd or the *resolved* cwd? |
| 231 | +Currently it receives the raw flag value (potentially relative), but the process has |
| 232 | +already `cd`'d into that directory. |
| 233 | + |
| 234 | +### 7. Middleware vs Command Extension Flag Parity |
| 235 | + |
| 236 | +**Scenario**: A lifecycle extension (middleware path) needs the `-e` environment name. |
| 237 | + |
| 238 | +**Current behavior**: Middleware reads `m.options.Flags.GetString("environment")` from |
| 239 | +Cobra persistent flags of the parent command (e.g., `azd deploy`). This works because |
| 240 | +the parent command *does* parse flags. |
| 241 | + |
| 242 | +**Gap**: The two execution paths (command extensions vs middleware extensions) have |
| 243 | +different flag resolution strategies. Command extensions cannot rely on Cobra flags; |
| 244 | +middleware extensions can. |
| 245 | + |
| 246 | +### 8. Help Text for Extension Commands |
| 247 | + |
| 248 | +**Scenario**: User runs `azd myext --help`. |
| 249 | + |
| 250 | +**Current behavior**: Because `DisableFlagParsing: true`, Cobra does not intercept |
| 251 | +`--help`. The `--help` flag is passed as an arg to the extension. The extension must |
| 252 | +handle it. Cobra's auto-generated help (which would show global flags) is not displayed. |
| 253 | + |
| 254 | +**Impact**: Users don't see a consistent help experience between azd commands and |
| 255 | +extension commands. Global flags like `-e`, `--debug` are not listed in extension help. |
| 256 | + |
| 257 | +## Recommendations |
| 258 | + |
| 259 | +These are observations about gaps in the current design, not prescriptive changes: |
| 260 | + |
| 261 | +1. **Document the flag contract**: Extensions should know definitively whether to read |
| 262 | + global flags from env vars or args. Today this is implicit. |
| 263 | + |
| 264 | +2. **Consider stripping global flags from extension args**: If azd globals are communicated |
| 265 | + via env vars, they arguably should not appear in the extension's command-line args. |
| 266 | + This would eliminate collision issues. However, this would be a breaking change for |
| 267 | + extensions that currently parse `-e` from their args. |
| 268 | + |
| 269 | +3. **Consider a `--` separator**: `azd [azd-flags] myext -- [ext-flags]` would make the |
| 270 | + boundary explicit. This is a convention used by many CLI tools (npm, cargo, kubectl). |
| 271 | + |
| 272 | +4. **Unify flag resolution**: The divergence between command extensions (can't use Cobra |
| 273 | + flags) and middleware extensions (can use Cobra flags) is a source of bugs. A single |
| 274 | + resolution path through `GlobalCommandOptions` would be more robust. |
| 275 | + |
| 276 | +5. **Document `AZD_ENVIRONMENT`, `AZD_DEBUG`, `AZD_NO_PROMPT`, `AZD_CWD`**: These |
| 277 | + environment variables are set for extension processes but not listed in |
| 278 | + `docs/environment-variables.md`. |
| 279 | + |
| 280 | +## File Reference |
| 281 | + |
| 282 | +| File | Role | |
| 283 | +|------|------| |
| 284 | +| `cmd/auto_install.go` | `CreateGlobalFlagSet()`, `ParseGlobalFlags()` | |
| 285 | +| `cmd/extensions.go` | `bindExtension()` (DisableFlagParsing), `extensionAction.Run()` | |
| 286 | +| `cmd/container.go` | `EnvFlag` DI resolver (environment name resolution chain) | |
| 287 | +| `cmd/middleware/extensions.go` | Lifecycle extension invocation (middleware path) | |
| 288 | +| `cmd/root.go` | Command tree construction, global flag registration | |
| 289 | +| `pkg/extensions/runner.go` | `InvokeOptions`, env var propagation, process spawning | |
| 290 | +| `internal/global_command_options.go` | `GlobalCommandOptions` struct | |
| 291 | +| `internal/env_flag.go` | `EnvFlag` type, `EnvironmentNameFlagName` constant | |
0 commit comments