Skip to content
97 changes: 89 additions & 8 deletions internal/cli/command_center.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
package cli

import (
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strconv"
"strings"
Expand All @@ -25,6 +27,24 @@
type modelSummary = zerocommands.ModelSnapshot
type providerCatalogSummary = zerocommands.ProviderCatalogSnapshot

// providerSourceUserConfig marks a profile saved in the user config file — the
// only place `zero providers use` can switch, since it writes that file.
// providerSourceResolved covers every other way a profile reaches the resolved
// config: project config, a provider command, or a profile synthesized from an
// ambient env var. Those are deliberately NOT called "runtime": some of them are
// persisted, just not where `providers use` writes, so the only guarantee the
// label carries is that the entry cannot be selected with that command.
const (
providerSourceUserConfig = "user-config"
providerSourceResolved = "resolved"
)

type providerCLISummary struct {
providerSummary
Selectable bool `json:"selectable"`
Source string `json:"source"`
}

func runConfig(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseCommandCenterArgs(args, false, false)
if err != nil {
Expand Down Expand Up @@ -125,15 +145,34 @@
return exitCode
}
summary := summarizeConfig(resolved)
providers := summary.Providers
userProviderNames, err := loadUserProviderNames(deps)
if err != nil {
return writeAppError(stderr, err.Error(), exitCrash)
}
providers := make([]providerCLISummary, 0, len(summary.Providers))
for _, provider := range summary.Providers {
// Resolution merges provider names case-sensitively (a project config
// or provider command can add a "WORK" entry alongside a persisted
// "work"), and `providers use` only ever matches config.json rows by
// their exact stored casing (see config.SetActiveProvider's Resolve()
// path). Folding case here would label a case-variant resolved entry
// selectable even though `providers use` cannot actually select it.
_, selectable := userProviderNames[strings.TrimSpace(provider.Name)]
source := providerSourceResolved
if selectable {
source = providerSourceUserConfig
}
providers = append(providers, providerCLISummary{providerSummary: provider, Selectable: selectable, Source: source})
}
if command == "current" {
providers = []providerSummary{}
for _, provider := range summary.Providers {
current := []providerCLISummary{}
for _, provider := range providers {
if provider.Active {
providers = append(providers, provider)
current = append(current, provider)
break
}
}
providers = current
}
if options.json {
if command == "current" {
Expand All @@ -151,12 +190,35 @@
}
return exitSuccess
}
if _, err := fmt.Fprintln(stdout, formatProviderSummaries(command, providers)); err != nil {
if _, err := fmt.Fprintln(stdout, formatProviderCLISummaries(command, providers)); err != nil {
return exitCrash
}
return exitSuccess
}

func loadUserProviderNames(deps appDeps) (map[string]struct{}, error) {
names := map[string]struct{}{}
path, err := deps.userConfigPath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return names, nil
}
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
var cfg config.FileConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("invalid config JSON %s: %w", path, err)
}
for _, provider := range cfg.Providers {
names[strings.TrimSpace(provider.Name)] = struct{}{}
}
return names, nil
}

func runModels(args []string, stdout io.Writer, stderr io.Writer) int {
if len(args) > 0 && (args[0] == "list" || args[0] == "ls") {
args = args[1:]
Expand Down Expand Up @@ -325,7 +387,18 @@
return strings.Join(lines, "\n")
}

// formatProviderSummaries renders a list whose selectability was not computed
// (the `zero config` summary and tests), so every entry reads as an ordinary
// saved profile and no misleading marker appears.
func formatProviderSummaries(command string, providers []providerSummary) string {

Check failure on line 393 in internal/cli/command_center.go

View workflow job for this annotation

GitHub Actions / Security & code health

unreachable func: formatProviderSummaries

Check failure on line 393 in internal/cli/command_center.go

View workflow job for this annotation

GitHub Actions / Smoke (windows-latest)

unreachable func: formatProviderSummaries
cliProviders := make([]providerCLISummary, 0, len(providers))
for _, provider := range providers {
cliProviders = append(cliProviders, providerCLISummary{providerSummary: provider, Selectable: true, Source: providerSourceUserConfig})
}
return formatProviderCLISummaries(command, cliProviders)
}

func formatProviderCLISummaries(command string, providers []providerCLISummary) string {
title := "Providers"
if command == "current" {
title = "Provider"
Expand All @@ -343,24 +416,32 @@
"model: "+displayCLIValue(provider.Model, "none"),
"api model: "+displayCLIValue(provider.APIModel, "unknown"),
"base url: "+displayCLIValue(provider.BaseURL, "default"),
"api key: "+providerCredentialState(provider),
"api key: "+providerCredentialState(provider.providerSummary),
fmt.Sprintf("selectable: %t (source: %s)", provider.Selectable, provider.Source),
)
if provider.Message != "" {
lines = append(lines, "status: "+provider.Status+" - "+provider.Message)
}
continue
}
lines = append(lines, " "+formatProviderLine(provider))
lines = append(lines, " "+formatProviderCLILine(provider))
}
return strings.Join(lines, "\n")
}

func formatProviderLine(provider providerSummary) string {
return formatProviderCLILine(providerCLISummary{providerSummary: provider, Selectable: true, Source: providerSourceUserConfig})
}

func formatProviderCLILine(provider providerCLISummary) string {
marker := " "
if provider.Active {
marker = "*"
}
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), providerCredentialState(provider))
line := fmt.Sprintf("%s %s [%s] model=%s apiModel=%s api key: %s", marker, displayCLIValue(provider.Name, "none"), displayCLIValue(provider.ProviderKind, "unknown"), displayCLIValue(provider.Model, "none"), displayCLIValue(provider.APIModel, "unknown"), providerCredentialState(provider.providerSummary))
if !provider.Selectable {
line += " (not selectable via providers use)"
}
if provider.Message != "" {
line += " (" + provider.Status + ": " + provider.Message + ")"
}
Expand Down
90 changes: 90 additions & 0 deletions internal/cli/command_center_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,96 @@ func TestRunProvidersCurrentJSONIncludesRuntimeMetadata(t *testing.T) {
}
}

func TestRunProvidersListMarksUserAndRuntimeProfiles(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{Providers: []config.ProviderProfile{{Name: "saved"}}})
deps := commandCenterDeps(t)
deps.userConfigPath = func() (string, error) { return configPath, nil }
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
profiles := []config.ProviderProfile{{Name: "saved", ProviderKind: config.ProviderKindOpenAI, Model: "gpt-4.1"}, {Name: "runtime", ProviderKind: config.ProviderKindOpenAICompatible, Model: "runtime-model"}}
return config.ResolvedConfig{ActiveProvider: "runtime", Provider: profiles[1], Providers: profiles}, nil
}

var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"providers", "list", "--json"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("code=%d stderr=%s", code, stderr.String())
}
var payload struct {
Providers []struct {
Name string `json:"name"`
Selectable bool `json:"selectable"`
Source string `json:"source"`
} `json:"providers"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if len(payload.Providers) != 2 || payload.Providers[0].Name != "runtime" || payload.Providers[0].Selectable || payload.Providers[0].Source != "resolved" || !payload.Providers[1].Selectable || payload.Providers[1].Source != "user-config" {
t.Fatalf("unexpected providers: %#v", payload.Providers)
}

stdout.Reset()
stderr.Reset()
if code := runWithDeps([]string{"providers", "list"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("code=%d stderr=%s", code, stderr.String())
}
if !strings.Contains(stdout.String(), "not selectable via providers use") {
t.Fatalf("non-selectable marker missing: %s", stdout.String())
}
}

// Resolution merges provider names case-sensitively (internal/config/resolver.go
// mergeProvider), so a project config or provider command can add a "WORK" entry
// alongside a persisted "work" as two distinct resolved profiles. `providers use`
// only ever matches config.json rows by their exact stored casing, so it can
// select "work" but has no way to select the case-variant "WORK" entry. Folding
// case when deriving selectable/source would mislabel "WORK" as selectable too.
func TestRunProvidersListDoesNotFoldCaseForSelectability(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
writeProviderOnboardingConfig(t, configPath, config.FileConfig{Providers: []config.ProviderProfile{{Name: "work"}}})
deps := commandCenterDeps(t)
deps.userConfigPath = func() (string, error) { return configPath, nil }
deps.resolveConfig = func(string, config.Overrides) (config.ResolvedConfig, error) {
profiles := []config.ProviderProfile{
{Name: "work", ProviderKind: config.ProviderKindOpenAI, Model: "gpt-4.1"},
{Name: "WORK", ProviderKind: config.ProviderKindOpenAICompatible, Model: "other-model"},
}
return config.ResolvedConfig{ActiveProvider: "work", Provider: profiles[0], Providers: profiles}, nil
}

var stdout, stderr bytes.Buffer
if code := runWithDeps([]string{"providers", "list", "--json"}, &stdout, &stderr, deps); code != exitSuccess {
t.Fatalf("code=%d stderr=%s", code, stderr.String())
}
var payload struct {
Providers []struct {
Name string `json:"name"`
Selectable bool `json:"selectable"`
Source string `json:"source"`
} `json:"providers"`
}
if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil {
t.Fatal(err)
}
if len(payload.Providers) != 2 {
t.Fatalf("unexpected providers: %#v", payload.Providers)
}
for _, provider := range payload.Providers {
switch provider.Name {
case "work":
if !provider.Selectable || provider.Source != "user-config" {
t.Fatalf("exact-case persisted entry should be selectable: %#v", provider)
}
case "WORK":
if provider.Selectable || provider.Source != "resolved" {
t.Fatalf("case-variant resolved entry must not be marked selectable: %#v", provider)
}
default:
t.Fatalf("unexpected provider name: %#v", provider)
}
}
}

func TestRunProvidersCatalogListsDescriptors(t *testing.T) {
var stdout bytes.Buffer
var stderr bytes.Buffer
Expand Down
79 changes: 74 additions & 5 deletions internal/cli/provider_onboarding.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,19 +71,34 @@ func runProvidersUse(args []string, stdout io.Writer, stderr io.Writer, deps app
}
cfg, err := config.SetActiveProvider(configPath, options.name)
if err != nil {
if strings.Contains(err.Error(), "provider ") && strings.Contains(err.Error(), " not found") {
err = fmt.Errorf("%w; only providers saved in user config are selectable (use zero providers setup or zero providers add first)", err)
}
return writeAppError(stderr, err.Error(), exitCrash)
}

override := activeProviderEnvOverride(deps.getenv, cfg.ActiveProvider)
// An override only becomes the effective provider if Zero can actually
// resolve it; a stale value names nothing and fails the next resolution.
overrideResolution := activeProviderOverrideAbsent
if override != "" {
overrideResolution = activeProviderEnvOverrideResolution(deps, configPath, override)
}
if options.json {
payload := map[string]any{
"activeProvider": cfg.ActiveProvider,
"configPath": configPath,
}
if override != "" {
// A JSON consumer must not read this as an effective switch either.
payload["effectiveProvider"] = override
payload["overriddenByEnv"] = config.ActiveProviderEnv
payload["envProvider"] = override
payload["envProviderResolves"] = overrideResolution.resolves()
if overrideResolution == activeProviderOverrideDeferred {
payload["envProviderResolution"] = "deferred"
}
if overrideResolution == activeProviderOverrideResolved {
payload["effectiveProvider"] = override
}
}
if err := writePrettyJSON(stdout, payload); err != nil {
return exitCrash
Expand All @@ -94,13 +109,41 @@ func runProvidersUse(args []string, stdout io.Writer, stderr io.Writer, deps app
return exitCrash
}
if override != "" {
if _, err := fmt.Fprintf(stderr, "Note: %s=%s is set and overrides config.json, so %s stays the active provider until you unset %s.\n", config.ActiveProviderEnv, override, override, config.ActiveProviderEnv); err != nil {
note := fmt.Sprintf("Note: %s=%s is set and overrides config.json, so %s stays the active provider until you unset %s.\n", config.ActiveProviderEnv, override, override, config.ActiveProviderEnv)
if overrideResolution == activeProviderOverrideDeferred {
note = fmt.Sprintf("Note: %s=%s is set and overrides config.json; %s is also set, so the effective provider will be determined when Zero next resolves configuration.\n", config.ActiveProviderEnv, override, config.ProviderCommandEnv)
} else if overrideResolution != activeProviderOverrideResolved {
// Naming it "effective" would be wrong: nothing resolves under that
// name, so the next command fails rather than using it.
note = fmt.Sprintf("Note: %s=%s is set and overrides config.json, but no provider named %s can be resolved, so Zero cannot start until you unset %s or point it at a saved provider.\n", config.ActiveProviderEnv, override, override, config.ActiveProviderEnv)
}
if _, err := fmt.Fprint(stderr, note); err != nil {
return exitCrash
}
}
return exitSuccess
}

type activeProviderOverrideResolution uint8

const (
activeProviderOverrideAbsent activeProviderOverrideResolution = iota
activeProviderOverrideResolved
activeProviderOverrideUnresolved
activeProviderOverrideDeferred
)

func (resolution activeProviderOverrideResolution) resolves() any {
switch resolution {
case activeProviderOverrideResolved:
return true
case activeProviderOverrideUnresolved:
return false
default:
return nil
}
}

// activeProviderEnvOverride returns the ZERO_PROVIDER value when it is set and
// names a DIFFERENT provider than the one just selected, meaning the saved
// `providers use` selection will NOT be the effective active provider until the
Expand All @@ -113,12 +156,38 @@ func activeProviderEnvOverride(getenv func(string) string, selected string) stri
return ""
}
override := strings.TrimSpace(getenv(config.ActiveProviderEnv))
if override == "" || strings.EqualFold(override, strings.TrimSpace(selected)) {
if override == "" || override == strings.TrimSpace(selected) {
return ""
}
return override
}

// activeProviderEnvOverrideResolution checks whether ZERO_PROVIDER resolves
// without running ZERO_PROVIDER_COMMAND. Provider commands are arbitrary
// external programs and `providers use` must remain a config-only operation;
// when one is configured, the final provider is therefore explicitly deferred
// until the next normal resolution instead of being guessed here.
func activeProviderEnvOverrideResolution(deps appDeps, configPath string, override string) activeProviderOverrideResolution {
if deps.getenv != nil && strings.TrimSpace(deps.getenv(config.ProviderCommandEnv)) != "" {
return activeProviderOverrideDeferred
}
workspaceRoot, err := resolveWorkspaceRoot("", deps)
if err != nil {
return activeProviderOverrideUnresolved
}
options, err := config.DefaultResolveOptions(workspaceRoot)
if err != nil {
return activeProviderOverrideUnresolved
}
options.UserConfigPath = configPath
options.ProviderCommand = ""
resolved, err := config.Resolve(options)
if err != nil || strings.TrimSpace(resolved.ActiveProvider) != override {
return activeProviderOverrideUnresolved
}
return activeProviderOverrideResolved
}

func runProvidersSetup(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
options, help, err := parseProviderSetupArgs(args)
if err != nil {
Expand Down Expand Up @@ -526,7 +595,7 @@ func runProvidersRename(args []string, stdout io.Writer, stderr io.Writer, deps
func providerResolvedByName(providers []config.ProviderProfile, name string) bool {
name = strings.TrimSpace(name)
for _, provider := range providers {
if strings.EqualFold(strings.TrimSpace(provider.Name), name) {
if strings.TrimSpace(provider.Name) == name {
return true
}
}
Expand Down
Loading
Loading