Skip to content

Commit 03455ad

Browse files
committed
fix(cli): enter setup on a requires-model config and hint zero auth login
Two residual first-run gaps after the catalog model-defaulting fix: - An active provider whose model has NO catalog default to fall back on (custom openai-/anthropic-compatible endpoints) still bricked bare `zero` and `zero setup` — the exact commands that could have repaired the config. Tag that resolve failure with ErrProviderRequiresModel and extend the interactive TUI's fixable-error fallback (previously only ErrNoActiveProvider) to drop into the setup wizard for it. The sentinel is attached via a message-preserving wrapper (multi-target Unwrap), so headless commands (zero config / zero exec) print the exact actionable text unchanged and every other resolve error stays fatal. - `zero login` / `zero logout` don't exist (it's `zero auth login`); first-run users try them and got only "unknown command" (reported in the wild). The unknown-command error now answers: did you mean "zero auth login"? Unrelated unknown commands get no suggestion. Tests: the sentinel comes from a REAL Resolve over a real config file (message-prefix asserted, no key leak); the TUI fallback is red-green (reverting the condition reproduces the exact brick with the old error); the existing no-active-provider fallback and other-errors-stay-fatal tests still pass; login/logout hint with a no-false-positive check.
1 parent 0e7a1fc commit 03455ad

4 files changed

Lines changed: 140 additions & 6 deletions

File tree

‎internal/cli/app.go‎

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,14 @@ func runWithDeps(args []string, stdout io.Writer, stderr io.Writer, deps appDeps
402402
if _, err := fmt.Fprintf(stderr, "unknown command %q\n", args[0]); err != nil {
403403
return 1
404404
}
405+
// First-run users reach for `zero login`/`zero logout` (reported in the
406+
// wild); point them at the real command instead of a bare usage pointer.
407+
switch strings.ToLower(args[0]) {
408+
case "login", "logout":
409+
if _, err := fmt.Fprintf(stderr, "did you mean %q?\n", "zero auth "+strings.ToLower(args[0])); err != nil {
410+
return 1
411+
}
412+
}
405413
if _, err := fmt.Fprintln(stderr, "Run zero --help for usage."); err != nil {
406414
return 1
407415
}
@@ -530,11 +538,17 @@ func runInteractiveTUIWithSetup(stderr io.Writer, deps appDeps, permissionMode a
530538

531539
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
532540
if err != nil {
533-
// A resolve failure caused solely by a missing/unresolvable active provider
534-
// is not fatal for the interactive TUI: drop into the setup wizard with an
535-
// empty config so the user can onboard, instead of exiting with an error.
536-
// Any other error (malformed JSON, directory conflict, etc.) is still fatal.
537-
if !errors.Is(err, config.ErrNoActiveProvider) {
541+
// A resolve failure the setup wizard can FIX is not fatal for the
542+
// interactive TUI: drop into the wizard with an empty config so the user
543+
// can onboard/repair, instead of exiting with an error they can only fix
544+
// by hand-editing config.json. That covers a missing/unresolvable active
545+
// provider and an active provider without a model (custom endpoints have
546+
// no catalog default) — previously the second shape bricked bare `zero`
547+
// and `zero setup`, the exact commands that could have fixed it. Any
548+
// other error (malformed JSON, directory conflict, etc.) is still fatal,
549+
// and headless commands (zero config / zero exec) still fail with the
550+
// actionable message.
551+
if !errors.Is(err, config.ErrNoActiveProvider) && !errors.Is(err, config.ErrProviderRequiresModel) {
538552
return writeAppError(stderr, err.Error(), 1)
539553
}
540554
resolved = config.ResolvedConfig{}

‎internal/cli/app_test.go‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1476,3 +1476,75 @@ func TestRunThemeFlagRejectsBadValue(t *testing.T) {
14761476
t.Fatalf("expected a clear --theme error, got %q", stderr.String())
14771477
}
14781478
}
1479+
1480+
// A custom endpoint saved without a model previously bricked bare `zero` and
1481+
// `zero setup` — the exact commands that could have fixed it (the resolve
1482+
// error escaped before the wizard could open). The interactive TUI now treats
1483+
// the requires-model failure as "needs onboarding", same as a missing active
1484+
// provider. The error comes from a REAL Resolve over a real config file, so
1485+
// this exercises the production sentinel wrapping, not a hand-built error.
1486+
func TestRunNoArgsEntersSetupWhenActiveProviderMissesModel(t *testing.T) {
1487+
var stdout bytes.Buffer
1488+
var stderr bytes.Buffer
1489+
cwd := t.TempDir()
1490+
setCLIUserConfigRoot(t)
1491+
brokenConfig := filepath.Join(t.TempDir(), "config.json")
1492+
if err := os.WriteFile(brokenConfig, []byte(`{
1493+
"activeProvider": "gw",
1494+
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
1495+
}`), 0o600); err != nil {
1496+
t.Fatal(err)
1497+
}
1498+
launched := false
1499+
var launchedOptions tui.Options
1500+
1501+
exitCode := runWithDeps([]string{}, &stdout, &stderr, appDeps{
1502+
getwd: func() (string, error) { return cwd, nil },
1503+
resolveConfig: func(string, config.Overrides) (config.ResolvedConfig, error) {
1504+
return config.Resolve(config.ResolveOptions{UserConfigPath: brokenConfig, Env: map[string]string{}})
1505+
},
1506+
newProvider: func(config.ProviderProfile) (zeroruntime.Provider, error) {
1507+
t.Fatal("newProvider must not be called without a resolved provider")
1508+
return nil, nil
1509+
},
1510+
userConfigPath: func() (string, error) { return filepath.Join(t.TempDir(), "zero", "config.json"), nil },
1511+
registerMCPTools: func(context.Context, *tools.Registry, config.MCPConfig, mcp.RegisterOptions) (mcpToolRuntime, error) {
1512+
return noopMCPRuntime{}, nil
1513+
},
1514+
runTUI: func(_ context.Context, options tui.Options) int {
1515+
launched = true
1516+
launchedOptions = options
1517+
return 0
1518+
},
1519+
})
1520+
1521+
if exitCode != 0 {
1522+
t.Fatalf("exit code = %d, want 0 (setup TUI launched), stderr=%q", exitCode, stderr.String())
1523+
}
1524+
if !launched {
1525+
t.Fatal("TUI was not launched; a fixable requires-model config must fall into setup, not exit fatally")
1526+
}
1527+
if !launchedOptions.Setup.Visible || !launchedOptions.Setup.Required {
1528+
t.Fatalf("Setup = %#v, want visible required setup", launchedOptions.Setup)
1529+
}
1530+
}
1531+
1532+
// `zero login`/`zero logout` don't exist (it's `zero auth login`); first-run
1533+
// users try them (reported in the wild), so the unknown-command error points at
1534+
// the real command.
1535+
func TestRunUnknownLoginSuggestsAuthLogin(t *testing.T) {
1536+
var stdout, stderr bytes.Buffer
1537+
exitCode := runWithDeps([]string{"login"}, &stdout, &stderr, appDeps{})
1538+
if exitCode != 2 {
1539+
t.Fatalf("exit = %d, want 2 (unknown command)", exitCode)
1540+
}
1541+
if !strings.Contains(stderr.String(), `"zero auth login"`) {
1542+
t.Fatalf("stderr = %q, want a zero auth login suggestion", stderr.String())
1543+
}
1544+
// An unrelated unknown command gets no misleading suggestion.
1545+
stderr.Reset()
1546+
runWithDeps([]string{"frobnicate"}, &stdout, &stderr, appDeps{})
1547+
if strings.Contains(stderr.String(), "did you mean") {
1548+
t.Fatalf("stderr = %q, unrelated commands must not get the auth hint", stderr.String())
1549+
}
1550+
}

‎internal/config/resolver.go‎

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,26 @@ import (
2020
// onboarding" (drop into the setup wizard) rather than a fatal config error.
2121
var ErrNoActiveProvider = errors.New("no active provider configured")
2222

23+
// ErrProviderRequiresModel marks a resolve failure caused solely by the active
24+
// provider missing a model with no catalog default to fall back on (custom
25+
// openai-/anthropic-compatible endpoints — Zero cannot guess a gateway's model).
26+
// Like ErrNoActiveProvider, the interactive TUI treats it as "needs onboarding"
27+
// and drops into the setup wizard so the user can fix it; headless commands
28+
// (zero config, zero exec) still fail with the actionable message.
29+
var ErrProviderRequiresModel = errors.New("provider requires model")
30+
31+
// setupFixableError tags an error with a sentinel for errors.Is WITHOUT
32+
// changing its message: the sentinel's own text must not prefix what the user
33+
// sees (the wrapped message is already complete and actionable).
34+
type setupFixableError struct {
35+
err error
36+
sentinel error
37+
}
38+
39+
func (e *setupFixableError) Error() string { return e.err.Error() }
40+
41+
func (e *setupFixableError) Unwrap() []error { return []error{e.err, e.sentinel} }
42+
2343
// defaultMaxTurns is the per-run tool-turn budget when none is configured. 30 was
2444
// too low for real multi-step agentic work (agents ran out mid-task before reaching
2545
// later steps); 50 matches the old "deep" preset. Raise per-session with /turns.
@@ -808,7 +828,10 @@ func normalizeProvidersWithOptions(providers []ProviderProfile, activeName strin
808828
return nil, ProviderProfile{}, fmt.Errorf("%w: active provider %q not found", ErrNoActiveProvider, activeName)
809829
}
810830
if active.Model == "" {
811-
return nil, ProviderProfile{}, providerError(active, "provider %s requires model — add \"model\" to its entry in config.json, or re-run: zero setup <catalog-id> --model <model>", active.Name)
831+
return nil, ProviderProfile{}, &setupFixableError{
832+
err: providerError(active, "provider %s requires model — add \"model\" to its entry in config.json, or re-run: zero setup <catalog-id> --model <model>", active.Name),
833+
sentinel: ErrProviderRequiresModel,
834+
}
812835
}
813836

814837
return normalized, active, nil

‎internal/config/resolver_test.go‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1624,3 +1624,28 @@ func TestNormalizeWithoutModelDefaultsStillRequiresGoogleModel(t *testing.T) {
16241624
t.Fatalf("error = %q, want an actionable config.json hint", err.Error())
16251625
}
16261626
}
1627+
1628+
// The residual requires-model failure (custom endpoint, no catalog default)
1629+
// must be tagged setup-fixable WITHOUT changing its message: the interactive
1630+
// TUI matches the sentinel to drop into the wizard, while headless commands
1631+
// print the exact actionable text.
1632+
func TestResolveRequiresModelErrorIsSetupFixable(t *testing.T) {
1633+
path := writeConfig(t, `{
1634+
"activeProvider": "gw",
1635+
"providers": [{"name": "gw", "provider_kind": "openai-compatible", "baseURL": "https://gw.example/v1", "apiKey": "sk-x"}]
1636+
}`)
1637+
_, err := Resolve(ResolveOptions{UserConfigPath: path, Env: map[string]string{}})
1638+
if err == nil {
1639+
t.Fatal("expected requires-model error for a custom endpoint without model")
1640+
}
1641+
if !errors.Is(err, ErrProviderRequiresModel) {
1642+
t.Fatalf("error = %v, want errors.Is(err, ErrProviderRequiresModel)", err)
1643+
}
1644+
// The sentinel must NOT prefix the user-facing message.
1645+
if !strings.HasPrefix(err.Error(), "provider gw requires model") {
1646+
t.Fatalf("message = %q, want it to start with the provider error, not the sentinel text", err.Error())
1647+
}
1648+
if strings.Contains(err.Error(), "sk-x") {
1649+
t.Fatalf("error leaked API key: %q", err.Error())
1650+
}
1651+
}

0 commit comments

Comments
 (0)