github.com/anthropics/anthropic-sdk-go@v1.70.1/config/config.go (about) 1 package config 2 3 import ( 4 "encoding/json" 5 "fmt" 6 "log" 7 "os" 8 "path/filepath" 9 "regexp" 10 "runtime" 11 "strings" 12 "sync" 13 ) 14 15 // ConfigFileVersion is the version written to configs/<profile>.json. Absent 16 // on read implies "1.0". Two-part major.minor: bump major on backwards- 17 // incompatible shape changes (readers can reject), minor on additive 18 // changes that older readers can tolerate. 19 const ConfigFileVersion = "1.0" 20 21 // profileNamePattern restricts config profile names to characters that can 22 // never escape the configs/ directory (no path separators, no "..") and 23 // keeps the name shell-safe. 24 var profileNamePattern = regexp.MustCompile(`^[A-Za-z0-9_.-]+$`) 25 26 func validateProfileName(name string) error { 27 if name == "" { 28 return fmt.Errorf("profile name is empty") 29 } 30 if name == "." || name == ".." { 31 return fmt.Errorf("profile name %q is not allowed", name) 32 } 33 if strings.ContainsAny(name, `/\`) { 34 return fmt.Errorf("profile name %q must not contain path separators", name) 35 } 36 if !profileNamePattern.MatchString(name) { 37 return fmt.Errorf("profile name %q contains disallowed characters (allowed: letters, digits, '_', '.', '-')", name) 38 } 39 return nil 40 } 41 42 // Config holds the raw configuration for authenticating with the Anthropic 43 // API. It mirrors the data stored in config files (configs/<profile>.json) 44 // and can be constructed manually or loaded from disk with [LoadConfig]. 45 // 46 // Authentication-mode-specific fields live inside [AuthenticationInfo], a 47 // tagged union discriminated on [AuthenticationInfo.Type]. Top-level fields 48 // apply to every profile regardless of authentication mode. 49 type Config struct { 50 // Version is the file-format version. Set to [ConfigFileVersion] by 51 // [SaveProfile] on every write; absent on disk implies "1.0". 52 Version string `json:"version,omitempty"` 53 54 // AuthenticationInfo describes how this profile authenticates. Required. 55 AuthenticationInfo *AuthenticationInfo `json:"authentication"` 56 57 // BaseURL overrides the default API base URL. Optional. 58 BaseURL string `json:"base_url,omitempty"` 59 60 // OrganizationID is the Anthropic organization the profile targets. 61 OrganizationID string `json:"organization_id,omitempty"` 62 63 // WorkspaceID scopes requests to a specific workspace. For non-federation 64 // profiles it is sent as the anthropic-workspace-id request header; for 65 // oidc_federation profiles it is sent as workspace_id in the jwt-bearer 66 // exchange body instead (the minted token is already workspace-scoped). 67 WorkspaceID string `json:"workspace_id,omitempty"` 68 } 69 70 // AuthenticationType is the discriminator for [AuthenticationInfo]. 71 type AuthenticationType string 72 73 const ( 74 // AuthenticationTypeOIDCFederation exchanges a third-party OIDC JWT 75 // ("identity token") for a short-lived Anthropic access token via the 76 // jwt-bearer grant on /v1/oauth/token. 77 AuthenticationTypeOIDCFederation AuthenticationType = "oidc_federation" 78 79 // AuthenticationTypeUserOAuth authenticates with an access token minted 80 // through a user-interactive OAuth flow (e.g. `ant auth login`), with 81 // optional refresh-token rotation when a ClientID is configured. 82 AuthenticationTypeUserOAuth AuthenticationType = "user_oauth" 83 ) 84 85 // AuthenticationInfo is a tagged union discriminated on [AuthenticationInfo.Type]. 86 // On the wire it is flat: the top-level JSON object holds `type`, the shared 87 // `credentials_path`, and the variant-specific fields all at the same level. 88 // In Go, the variant-specific fields are grouped into strongly-typed sub-structs 89 // ([OIDCFederation], [UserOAuth]) so callers get type safety and a clean 90 // non-nil check per variant. Exactly one sub-struct pointer is populated, and 91 // it must match [AuthenticationInfo.Type]. 92 // 93 // Because the wire shape is flat, all variant field names share a single 94 // namespace at the JSON layer — new variants must pick field names that do 95 // not collide with shared fields or with each other. 96 // 97 // [AuthenticationInfo.UnmarshalJSON] silently ignores unknown fields at the 98 // JSON layer (per the credentials-file-format spec: "SDKs MUST silently 99 // ignore unrecognized top-level keys in both config and credentials files" 100 // — the same tolerance rule applies to the nested authentication object). 101 // Unknown authentication types still fail loud because the SDK has no way 102 // to meaningfully resolve credentials for an unknown variant. 103 type AuthenticationInfo struct { 104 Type AuthenticationType `json:"-"` 105 106 // CredentialsPath is the path to the credentials JSON file that stores 107 // access / refresh tokens on disk. Leave empty to use the default path 108 // (credentials/<profile>.json under the config directory). Shared across 109 // all authentication types. 110 CredentialsPath string `json:"-"` 111 112 // OIDCFederation holds the fields for Type == AuthenticationTypeOIDCFederation. 113 // Populated by UnmarshalJSON and inlined by MarshalJSON; never appears as 114 // a nested JSON object. 115 OIDCFederation *OIDCFederation `json:"-"` 116 117 // UserOAuth holds the fields for Type == AuthenticationTypeUserOAuth. 118 // Populated by UnmarshalJSON and inlined by MarshalJSON; never appears as 119 // a nested JSON object. 120 UserOAuth *UserOAuth `json:"-"` 121 } 122 123 // OIDCFederation configures a profile that authenticates by exchanging a 124 // third-party OIDC identity token for an Anthropic access token. Its fields 125 // are inlined into the parent [AuthenticationInfo] on the wire. 126 type OIDCFederation struct { 127 // FederationRuleID is the tagged ID ("fdrl_...") of the OidcFederationRule 128 // that governs the exchange. Required. 129 FederationRuleID string `json:"federation_rule_id"` 130 131 // ServiceAccountID is an optional expected-target check for federation 132 // rules with target_type=SERVICE_ACCOUNT. Must be a "svac_..." tagged ID. 133 // Omit for target_type=USER rules, where the principal is derived from 134 // the JWT claims. 135 ServiceAccountID string `json:"service_account_id,omitempty"` 136 137 // IdentityToken describes how to obtain the OIDC assertion this profile 138 // presents at token-exchange time. 139 IdentityToken *IdentityTokenConfig `json:"identity_token,omitempty"` 140 141 // Scope is the OAuth scope string (RFC 6749 §3.3 space-delimited form) 142 // the profile expects to be granted. It is stored on the profile for 143 // display and configuration purposes only — the SDK does NOT send it 144 // on the jwt-bearer exchange. The granted scope is determined by the 145 // federation rule on the server; IssueOAuthTokenRequest has no scope 146 // field, and the REST gateway's alias transformation strips unknown 147 // keys, so any attempt to wire this through to the exchange body 148 // would be silently dropped. The granted scope appears on the token 149 // response's `scope` field, but the SDK does not currently surface 150 // it to callers. 151 Scope string `json:"scope,omitempty"` 152 } 153 154 // UserOAuth configures a profile authenticated via a user-interactive OAuth 155 // flow. The access and (optional) refresh tokens live on disk under the 156 // profile's [AuthenticationInfo.CredentialsPath]. Its fields are inlined 157 // into the parent [AuthenticationInfo] on the wire. 158 type UserOAuth struct { 159 // ClientID is the OAuth client ID used for refresh-token exchange. When 160 // empty, the access token is treated as static (no refresh) and the 161 // profile fails once it expires. 162 ClientID string `json:"client_id,omitempty"` 163 164 // Scope is the OAuth scope string (RFC 6749 §3.3 space-delimited form) 165 // the profile was granted, captured at login time. The SDK does not 166 // consult this on refresh — the oauth-server preserves the original 167 // scope set when the refresh request omits scope — but `ant auth status` 168 // and similar tools display it. 169 Scope string `json:"scope,omitempty"` 170 171 // ConsoleURL is the base URL of the OAuth /authorize page for 172 // interactive login. The SDK does not consult it; it's CLI-only state 173 // (the `ant auth login` target) that lives on the shared schema so 174 // SaveProfile/LoadProfile round-trip it without triggering the 175 // unknown-field warning. 176 ConsoleURL string `json:"console_url,omitempty"` 177 } 178 179 // IdentityTokenSource is the source kind for an OIDC identity token. 180 type IdentityTokenSource string 181 182 const ( 183 // IdentityTokenSourceFile reads the token from a file on every exchange, 184 // which supports rotated tokens (e.g. Kubernetes projected service 185 // account tokens). 186 IdentityTokenSourceFile IdentityTokenSource = "file" 187 ) 188 189 // IdentityTokenConfig specifies how to obtain an OIDC identity token for 190 // federation exchange. 191 type IdentityTokenConfig struct { 192 Source IdentityTokenSource `json:"source"` 193 Path string `json:"path,omitempty"` 194 } 195 196 // NewUserOAuthAuthentication returns a populated [AuthenticationInfo] for 197 // the user_oauth variant. Pass an empty clientID to describe a profile whose 198 // access token is static (no refresh). 199 func NewUserOAuthAuthentication(clientID string) *AuthenticationInfo { 200 return &AuthenticationInfo{ 201 Type: AuthenticationTypeUserOAuth, 202 UserOAuth: &UserOAuth{ClientID: clientID}, 203 } 204 } 205 206 // NewOIDCFederationAuthentication builds an [AuthenticationInfo] for the 207 // oidc_federation variant. 208 func NewOIDCFederationAuthentication(oidc OIDCFederation) *AuthenticationInfo { 209 return &AuthenticationInfo{ 210 Type: AuthenticationTypeOIDCFederation, 211 OIDCFederation: &oidc, 212 } 213 } 214 215 // sharedAuthFields are the fields present on every authentication variant. 216 // The discriminator read uses a tolerant decoder (unknown fields allowed) 217 // because the variant decode in the second pass is what rejects typos. 218 type sharedAuthFields struct { 219 Type AuthenticationType `json:"type"` 220 CredentialsPath string `json:"credentials_path,omitempty"` 221 } 222 223 // oidcFederationWire is the flat wire shape for oidc_federation: shared 224 // fields + inlined OIDCFederation fields. Unknown fields are tolerated 225 // per the credentials-file-format spec so forward-compatible additions 226 // and cross-SDK-written files don't fail loud. 227 type oidcFederationWire struct { 228 sharedAuthFields 229 FederationRuleID string `json:"federation_rule_id"` 230 ServiceAccountID string `json:"service_account_id,omitempty"` 231 IdentityToken *IdentityTokenConfig `json:"identity_token,omitempty"` 232 Scope string `json:"scope,omitempty"` 233 } 234 235 // userOAuthWire is the flat wire shape for user_oauth: shared fields + 236 // inlined UserOAuth fields. 237 type userOAuthWire struct { 238 sharedAuthFields 239 ClientID string `json:"client_id,omitempty"` 240 Scope string `json:"scope,omitempty"` 241 ConsoleURL string `json:"console_url,omitempty"` 242 } 243 244 // UnmarshalJSON decodes the flat tagged-union wire shape into the nested 245 // Go representation. Unknown fields are silently tolerated per the 246 // credentials-file-format spec but are also logged (warn-once) so a 247 // user who typo'd a field name at least sees that it was ignored. 248 // Unknown authentication types still fail loud. 249 func (a *AuthenticationInfo) UnmarshalJSON(data []byte) error { 250 var raw map[string]json.RawMessage 251 if err := json.Unmarshal(data, &raw); err != nil { 252 return err 253 } 254 var authType AuthenticationType 255 if b, ok := raw["type"]; ok { 256 if err := json.Unmarshal(b, &authType); err != nil { 257 return err 258 } 259 } 260 261 switch authType { 262 case "": 263 return fmt.Errorf("authentication.type is required") 264 case AuthenticationTypeOIDCFederation: 265 var w oidcFederationWire 266 if err := json.Unmarshal(data, &w); err != nil { 267 return fmt.Errorf("authentication.type %q: %w", authType, err) 268 } 269 unknown := unknownRawKeys(raw, knownOIDCFederationFields) 270 *a = AuthenticationInfo{ 271 Type: w.Type, 272 CredentialsPath: w.CredentialsPath, 273 OIDCFederation: &OIDCFederation{ 274 FederationRuleID: w.FederationRuleID, 275 ServiceAccountID: w.ServiceAccountID, 276 IdentityToken: w.IdentityToken, 277 Scope: w.Scope, 278 }, 279 } 280 warnUnknownAuthFields(string(authType), unknown) 281 return nil 282 case AuthenticationTypeUserOAuth: 283 var w userOAuthWire 284 if err := json.Unmarshal(data, &w); err != nil { 285 return fmt.Errorf("authentication.type %q: %w", authType, err) 286 } 287 unknown := unknownRawKeys(raw, knownUserOAuthFields) 288 *a = AuthenticationInfo{ 289 Type: w.Type, 290 CredentialsPath: w.CredentialsPath, 291 UserOAuth: &UserOAuth{ 292 ClientID: w.ClientID, 293 Scope: w.Scope, 294 ConsoleURL: w.ConsoleURL, 295 }, 296 } 297 warnUnknownAuthFields(string(authType), unknown) 298 return nil 299 default: 300 return fmt.Errorf("authentication.type %q is not a known authentication type", authType) 301 } 302 } 303 304 func unknownRawKeys(raw map[string]json.RawMessage, known map[string]struct{}) []string { 305 var unknown []string 306 for k := range raw { 307 if _, ok := known[k]; ok { 308 continue 309 } 310 unknown = append(unknown, k) 311 } 312 return unknown 313 } 314 315 // Keep in sync with [oidcFederationWire] and [userOAuthWire]. 316 var ( 317 knownOIDCFederationFields = map[string]struct{}{ 318 "type": {}, 319 "credentials_path": {}, 320 "federation_rule_id": {}, 321 "service_account_id": {}, 322 "identity_token": {}, 323 "scope": {}, 324 } 325 knownUserOAuthFields = map[string]struct{}{ 326 "type": {}, 327 "credentials_path": {}, 328 "client_id": {}, 329 "scope": {}, 330 "console_url": {}, 331 } 332 ) 333 334 var ( 335 configWarnOnceMu sync.Mutex 336 configWarnOnceSet = map[string]bool{} 337 ) 338 339 func configWarnOnce(key, format string, args ...any) { 340 configWarnOnceMu.Lock() 341 already := configWarnOnceSet[key] 342 configWarnOnceSet[key] = true 343 configWarnOnceMu.Unlock() 344 if already { 345 return 346 } 347 log.Printf("anthropic-sdk-go/config: "+format, args...) 348 } 349 350 func warnUnknownAuthFields(authType string, fields []string) { 351 for _, f := range fields { 352 configWarnOnce( 353 "unknown-auth-field:"+authType+":"+f, 354 "profile authentication.%s: unknown field %q (ignored)", 355 authType, f, 356 ) 357 } 358 } 359 360 // ResetConfigWarnOnceForTest clears the warn-once dedupe state so tests 361 // can observe a warning a prior test may already have triggered. 362 func ResetConfigWarnOnceForTest() { 363 configWarnOnceMu.Lock() 364 configWarnOnceSet = map[string]bool{} 365 configWarnOnceMu.Unlock() 366 } 367 368 // MarshalJSON emits the flat tagged-union wire shape: shared fields, then 369 // the matching variant's fields inlined at the same level. Returns an error 370 // when the in-memory state is inconsistent (Type mismatches the populated 371 // sub-struct, or neither/both are set). 372 func (a AuthenticationInfo) MarshalJSON() ([]byte, error) { 373 switch a.Type { 374 case "": 375 return nil, fmt.Errorf("AuthenticationInfo.Type is required") 376 case AuthenticationTypeOIDCFederation: 377 if a.OIDCFederation == nil { 378 return nil, fmt.Errorf("AuthenticationInfo.Type=%q requires OIDCFederation", a.Type) 379 } 380 if a.UserOAuth != nil { 381 return nil, fmt.Errorf("AuthenticationInfo.Type=%q must not set UserOAuth", a.Type) 382 } 383 return json.Marshal(oidcFederationWire{ 384 sharedAuthFields: sharedAuthFields{Type: a.Type, CredentialsPath: a.CredentialsPath}, 385 FederationRuleID: a.OIDCFederation.FederationRuleID, 386 ServiceAccountID: a.OIDCFederation.ServiceAccountID, 387 IdentityToken: a.OIDCFederation.IdentityToken, 388 Scope: a.OIDCFederation.Scope, 389 }) 390 case AuthenticationTypeUserOAuth: 391 if a.UserOAuth == nil { 392 return nil, fmt.Errorf("AuthenticationInfo.Type=%q requires UserOAuth", a.Type) 393 } 394 if a.OIDCFederation != nil { 395 return nil, fmt.Errorf("AuthenticationInfo.Type=%q must not set OIDCFederation", a.Type) 396 } 397 return json.Marshal(userOAuthWire{ 398 sharedAuthFields: sharedAuthFields{Type: a.Type, CredentialsPath: a.CredentialsPath}, 399 ClientID: a.UserOAuth.ClientID, 400 Scope: a.UserOAuth.Scope, 401 ConsoleURL: a.UserOAuth.ConsoleURL, 402 }) 403 default: 404 return nil, fmt.Errorf("AuthenticationInfo.Type %q is not a known authentication type", a.Type) 405 } 406 } 407 408 // defaultConfigDir returns the platform-specific base directory for Anthropic 409 // config and credentials. It checks ANTHROPIC_CONFIG_DIR first, then falls 410 // back to the platform default. On non-Windows platforms XDG_CONFIG_HOME is 411 // honored per the XDG Base Directory spec before the $HOME/.config fallback. 412 // Returns an empty string when the platform home directory cannot be 413 // resolved (e.g., $HOME unset in a minimal container), so callers don't 414 // silently read/write to a CWD-relative path. 415 func defaultConfigDir() string { 416 if dir, ok := os.LookupEnv("ANTHROPIC_CONFIG_DIR"); ok { 417 return dir 418 } 419 if runtime.GOOS == "windows" { 420 if appdata := os.Getenv("APPDATA"); appdata != "" { 421 return filepath.Join(appdata, "Anthropic") 422 } 423 return "" 424 } 425 if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { 426 return filepath.Join(xdg, "anthropic") 427 } 428 home, err := os.UserHomeDir() 429 if err != nil || home == "" { 430 return "" 431 } 432 return filepath.Join(home, ".config", "anthropic") 433 } 434 435 // resolveProfile determines the active profile name. 436 // Resolution order: ANTHROPIC_PROFILE env -> active_config file -> "default". 437 func resolveProfile(configDir string) string { 438 if profile, ok := os.LookupEnv("ANTHROPIC_PROFILE"); ok { 439 return profile 440 } 441 data, err := os.ReadFile(filepath.Join(configDir, "active_config")) 442 if err == nil { 443 if name := strings.TrimSpace(string(data)); name != "" { 444 return name 445 } 446 } 447 return "default" 448 } 449 450 // loadProfile reads the config file for a specific profile and returns the 451 // parsed [Config]. The [Config.CredentialsPath] is defaulted to 452 // credentials/<profile>.json under configDir if not already set. 453 func loadProfile(configDir, profile string) (*Config, error) { 454 if err := validateProfileName(profile); err != nil { 455 return nil, fmt.Errorf("invalid profile %q: %w", profile, err) 456 } 457 configPath := filepath.Join(configDir, "configs", profile+".json") 458 data, err := os.ReadFile(configPath) 459 if err != nil { 460 return nil, fmt.Errorf("failed to read config file %q: %w", configPath, err) 461 } 462 463 var cfg Config 464 if err := json.Unmarshal(data, &cfg); err != nil { 465 return nil, fmt.Errorf("failed to parse config file %q: %w", configPath, err) 466 } 467 468 if cfg.AuthenticationInfo == nil { 469 return nil, fmt.Errorf("config file %q is missing authentication", configPath) 470 } 471 472 if cfg.AuthenticationInfo.CredentialsPath == "" { 473 cfg.AuthenticationInfo.CredentialsPath = filepath.Join(configDir, "credentials", profile+".json") 474 } 475 476 fillMissingFromEnv(&cfg) 477 478 return &cfg, nil 479 } 480 481 const ( 482 envBaseURL = "ANTHROPIC_BASE_URL" 483 envOrganizationID = "ANTHROPIC_ORGANIZATION_ID" 484 envWorkspaceID = "ANTHROPIC_WORKSPACE_ID" 485 envFederationRuleID = "ANTHROPIC_FEDERATION_RULE_ID" 486 envServiceAccountID = "ANTHROPIC_SERVICE_ACCOUNT_ID" 487 envScope = "ANTHROPIC_SCOPE" 488 envIdentityTokenFile = "ANTHROPIC_IDENTITY_TOKEN_FILE" 489 ) 490 491 // fillMissingFromEnv populates cfg fields from ANTHROPIC_* environment 492 // variables, but only when the corresponding field is empty in the loaded 493 // profile. Contract lives in the cross-SDK credential precedence spec 494 // (sdk_credential_precedence.md, section 1 interaction rules): "env vars 495 // fill in fields omitted from a profile file, but never override present 496 // ones." This keeps an explicit profile authoritative on a machine that 497 // also happens to have WIF env vars exported (e.g., a prod profile is 498 // not silently replaced by a leftover dev federation_rule_id env var). 499 // 500 // Empty env values are treated as unset rather than "clear the field" so 501 // an accidentally-blanked env var can't silently strip a required field. 502 func fillMissingFromEnv(cfg *Config) { 503 if cfg.BaseURL == "" { 504 if v, ok := lookupNonEmpty(envBaseURL); ok { 505 cfg.BaseURL = v 506 } 507 } 508 if cfg.OrganizationID == "" { 509 if v, ok := lookupNonEmpty(envOrganizationID); ok { 510 cfg.OrganizationID = v 511 } 512 } 513 if cfg.WorkspaceID == "" { 514 if v, ok := lookupNonEmpty(envWorkspaceID); ok { 515 cfg.WorkspaceID = v 516 } 517 } 518 519 switch cfg.AuthenticationInfo.Type { 520 case AuthenticationTypeOIDCFederation: 521 oidc := cfg.AuthenticationInfo.OIDCFederation 522 if oidc == nil { 523 return 524 } 525 if oidc.FederationRuleID == "" { 526 if v, ok := lookupNonEmpty(envFederationRuleID); ok { 527 oidc.FederationRuleID = v 528 } 529 } 530 if oidc.ServiceAccountID == "" { 531 if v, ok := lookupNonEmpty(envServiceAccountID); ok { 532 oidc.ServiceAccountID = v 533 } 534 } 535 if oidc.Scope == "" { 536 if v, ok := lookupNonEmpty(envScope); ok { 537 oidc.Scope = v 538 } 539 } 540 if oidc.IdentityToken == nil { 541 if v, ok := lookupNonEmpty(envIdentityTokenFile); ok { 542 oidc.IdentityToken = &IdentityTokenConfig{ 543 Source: IdentityTokenSourceFile, 544 Path: v, 545 } 546 } 547 } 548 case AuthenticationTypeUserOAuth: 549 if cfg.AuthenticationInfo.UserOAuth == nil { 550 return 551 } 552 if cfg.AuthenticationInfo.UserOAuth.Scope == "" { 553 if v, ok := lookupNonEmpty(envScope); ok { 554 cfg.AuthenticationInfo.UserOAuth.Scope = v 555 } 556 } 557 } 558 } 559 560 // lookupNonEmpty returns the env var's value and true only when it is set 561 // and non-empty. An empty value is reported as unset. 562 func lookupNonEmpty(key string) (string, bool) { 563 v, ok := os.LookupEnv(key) 564 if !ok || v == "" { 565 return "", false 566 } 567 return v, true 568 } 569 570 // LoadProfile loads the config for the named profile from the given config 571 // directory, bypassing ANTHROPIC_PROFILE / active_config resolution. Use 572 // [DefaultDir] for the standard location. This is the building block CLIs use 573 // to inspect or operate on a profile other than the currently-active one. 574 func LoadProfile(dir, profile string) (*Config, error) { 575 return loadProfile(dir, profile) 576 } 577 578 // LoadConfig reads the raw configuration from the Anthropic config file system 579 // (configs/<profile>.json) and returns it without resolving credentials. 580 // Credential resolution is deferred until the config is passed to 581 // [option.WithConfig]. 582 // 583 // The config directory and profile are resolved using the standard resolution 584 // order (ANTHROPIC_CONFIG_DIR, ANTHROPIC_PROFILE, active_config file, defaults). 585 func LoadConfig() (*Config, error) { 586 configDir := defaultConfigDir() 587 profile := resolveProfile(configDir) 588 if err := validateProfileName(profile); err != nil { 589 return nil, fmt.Errorf("invalid profile name from resolution: %w", err) 590 } 591 return loadProfile(configDir, profile) 592 }