Files
zero/internal/tui/command_views.go
ba99fa8d48 fix(provider): stop dropping custom no-auth providers on restart (#558)
* fix(provider): stop dropping custom no-auth providers on restart

Custom OpenAI/Anthropic-compatible catalog entries hardcode
RequiresAuth as a template default for the setup wizard, not a fact
about the user's actual endpoint. Leaving the credential step blank
(e.g. a local llama.cpp server) still stamped the saved profile with
a guessed OPENAI_API_KEY/ANTHROPIC_API_KEY env var, and that same
static RequiresAuth flag then made the profile look like it was
missing a required credential on the next run, silently filtering it
out of /model.

Add a Custom flag to the catalog descriptor so both the wizard
(write side) and setupMissingCredentialEnv (read side) judge these
profiles by their own explicit APIKeyEnv instead of the catalog's
static default.

Fixes #555

Co-Authored-By: Claude Sonnet 5 <[email protected]>

Co-authored-by: Pierre Bruno <[email protected]>

* fix(provider): self-heal legacy custom profiles stamped by the old wizard bug

CodeRabbit flagged that profiles saved before this fix could already
carry the phantom APIKeyEnv the old wizard stamped on every custom
provider (e.g. OPENAI_API_KEY), and the new custom-branch logic would
keep treating any non-empty APIKeyEnv as an explicit requirement —
leaving already-affected users' providers filtered out of /model even
after upgrading.

Treat an APIKeyEnv that exactly matches the catalog's own guessed
default as indistinguishable from unset, so already-broken saved
profiles self-heal without a config.json migration. A deliberately
different env var name (e.g. via --api-key-env) still means auth is
required.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

Co-authored-by: Pierre Bruno <[email protected]>

* fix(provider): centralize custom-endpoint credential logic, fix two more surfaces

Copilot's review of the earlier fix found the same RequiresAuth-as-
authoritative bug in two more places that decide whether a saved
provider needs a credential:

- validateProviderRuntimeReady (zero providers check) would hard-error
  a no-auth custom endpoint with "requires API key".
- providerWizardCredentialLabel and providerCredentialRequired (/providers
  status, wizard summary) would still claim/warn that one was needed.

Added config.ProviderProfile.MissingCredentialEnv as the single source
of truth for this decision (custom-endpoint self-heal included), and
had setupMissingCredentialEnv and validateProviderRuntimeReady delegate
to it. providerWizardCredentialLabel and providerCredentialRequired
take the same Custom-aware fix directly, since their non-catalog
fallback path uses runtime-resolved provider-kind metadata that
shouldn't be collapsed into the canonical helper.

Co-Authored-By: Claude Sonnet 5 <[email protected]>

Co-authored-by: Pierre Bruno <[email protected]>

* fix(ci): request pinned Go toolchain directly

Co-authored-by: Pierre Bruno <[email protected]>

---------

Co-authored-by: Amp <[email protected]>
2026-07-08 08:54:39 +08:00

736 lines
21 KiB
Go

package tui
import (
"context"
"fmt"
"sort"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/Gitlawb/zero/internal/config"
"github.com/Gitlawb/zero/internal/providercatalog"
"github.com/Gitlawb/zero/internal/sandbox"
"github.com/Gitlawb/zero/internal/tools"
"github.com/Gitlawb/zero/internal/zerocommands"
)
func (m model) toolsText() string {
registered := m.registeredTools()
count := len(registered)
if len(registered) == 0 {
return renderCommandCardTranscript(commandCard{
Title: "Tools",
Summary: []string{"0 registered", "no tools available"},
Sections: []commandCardSection{{
Title: "Registry",
Fields: []commandField{
{Key: "registered", Value: "0"},
},
}},
Actions: []string{"/mcp manage servers", "/permissions manage access"},
})
}
names := make([]string, 0, count)
for _, tool := range registered {
names = append(names, tool.Name())
}
sort.Strings(names)
available := make([]string, 0, len(names))
for _, name := range names {
available = append(available, commandBullet(name))
}
return renderCommandCardTranscript(commandCard{
Title: "Tools",
Summary: []string{fmt.Sprintf("%d registered", count), "registered catalog"},
Sections: []commandCardSection{
{
Title: "Registry",
Fields: []commandField{
{Key: "registered", Value: fmt.Sprint(count)},
},
},
{
Title: "Available",
Lines: available,
},
},
Actions: []string{"/mcp manage servers", "/permissions manage access"},
})
}
func (m *model) mcpText() string {
width := 0
if m.width > 0 {
width = chatWidth(m.width)
}
return renderMCPView(m.mcpViewState(), width)
}
func (m *model) refreshMCPViewState() {
m.mcpViewStateCache = BuildMCPViewState(MCPStateOptions{
Config: m.mcpConfig,
Registry: m.registry,
PermissionStore: m.mcpPermissionStore,
PermissionMode: string(m.permissionMode),
TokenStore: m.mcpTokenStore,
})
m.mcpViewStateReady = true
}
func (m *model) mcpViewState() MCPViewState {
if m.mcpViewStateReady {
return m.mcpViewStateCache
}
// Older tests may construct a zero-value model; keep that path useful, while
// production refreshes the cache before any MCP view can render.
m.refreshMCPViewState()
return m.mcpViewStateCache
}
func (m model) startMCPTranscriptCommand(args string) (model, tea.Cmd) {
args = strings.TrimSpace(args)
if args == "" {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "mcp", text: m.mcpText()})
return m, nil
}
parsedArgs, err := splitMCPCommandArgs(args)
if err != nil {
text := strings.Join([]string{
"MCP action failed",
err.Error(),
"",
m.mcpText(),
}, "\n")
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "mcp", text: text})
return m, nil
}
return m.startMCPCommand(mcpCommandRequest{origin: mcpCommandOriginTranscript, raw: args, args: parsedArgs})
}
func (m model) startMCPCommand(request mcpCommandRequest) (model, tea.Cmd) {
if m.mcpCommand == nil {
result := MCPCommandResult{
ExitCode: 1,
Error: "MCP action unavailable",
Config: m.mcpConfig,
}
return m.applyMCPCommandResultMessage(mcpCommandResultMsg{request: request, result: result}), nil
}
m.cancelMCPCommand()
ctx := m.ctx
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithCancel(ctx)
m.mcpCommandSeq++
request.id = m.mcpCommandSeq
request.args = append([]string{}, request.args...)
m.mcpCommandCancel = cancel
runner := m.mcpCommand
return m, func() tea.Msg {
return mcpCommandResultMsg{
request: request,
result: runner(ctx, request.args),
}
}
}
func (m *model) cancelMCPCommand() {
if m.mcpCommandCancel != nil {
m.mcpCommandCancel()
m.mcpCommandCancel = nil
m.mcpCommandSeq++
}
}
func (m model) applyMCPCommandResultMessage(msg mcpCommandResultMsg) model {
if msg.request.id != 0 && msg.request.id != m.mcpCommandSeq {
return m
}
m.mcpCommandCancel = nil
switch msg.request.origin {
case mcpCommandOriginManager:
text := ""
m, text = m.applyMCPCommandResult(strings.Join(msg.request.args, " "), msg.result)
m.mcpManager = &mcpManagerState{selected: msg.request.managerSelected, query: msg.request.managerQuery}
if items := m.mcpManagerItems(); len(items) > 0 {
m.mcpManager.selected = clampInt(m.mcpManager.selected, 0, len(items)-1)
}
if text != "" {
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "mcp", text: text})
}
case mcpCommandOriginWizard:
m = m.applyMCPAddWizardSaveResult(msg.result, msg.request.wizardDisabled)
default:
text := ""
m, text = m.applyMCPCommandResult(msg.request.raw, msg.result)
m.transcript = appendTranscriptRow(m.transcript, transcriptRow{kind: rowSystem, tool: "mcp", text: text})
}
return m
}
func (m model) applyMCPCommandResult(args string, result MCPCommandResult) (model, string) {
if result.ExitCode != 0 || strings.TrimSpace(result.Error) != "" {
message := strings.TrimSpace(result.Error)
if message == "" {
message = strings.TrimSpace(result.Output)
}
if message == "" {
message = "MCP command failed"
}
return m, strings.Join([]string{
"MCP action failed",
message,
"",
m.mcpText(),
}, "\n")
}
if len(result.Config.Servers) > 0 || len(m.mcpConfig.Servers) > 0 {
m.mcpConfig = result.Config
m.refreshMCPViewState()
}
output := strings.TrimSpace(result.Output)
if output == "" {
output = "zero mcp " + args
}
return m, strings.Join([]string{
"MCP action complete",
output,
"",
m.mcpText(),
}, "\n")
}
func splitMCPCommandArgs(args string) ([]string, error) {
args = strings.TrimSpace(args)
if args == "" {
return nil, nil
}
out := []string{}
var current strings.Builder
var quote rune
hasToken := false
runes := []rune(args)
for index := 0; index < len(runes); index++ {
r := runes[index]
if quote != 0 {
if r == quote {
quote = 0
hasToken = true
continue
}
if r == '\\' && index+1 < len(runes) && runes[index+1] == quote {
index++
current.WriteRune(runes[index])
hasToken = true
continue
}
current.WriteRune(r)
hasToken = true
continue
}
switch {
case r == '\'' || r == '"':
quote = r
hasToken = true
case r == '\\' && index+1 < len(runes) && (runes[index+1] == '\'' || runes[index+1] == '"' || strings.TrimSpace(string(runes[index+1])) == ""):
index++
current.WriteRune(runes[index])
hasToken = true
case strings.TrimSpace(string(r)) == "":
if hasToken {
out = append(out, current.String())
current.Reset()
hasToken = false
}
default:
current.WriteRune(r)
hasToken = true
}
}
if quote != 0 {
return nil, fmt.Errorf("unterminated quote in MCP command")
}
if hasToken {
out = append(out, current.String())
}
return out, nil
}
func (m model) permissionsText() string {
if m.sandboxStore == nil {
return m.permissionsTextWithStore(nil)
}
return m.permissionsTextWithStore(m.sandboxStore)
}
func (m model) permissionsTextWithStore(store grantLister) string {
mode := string(m.permissionMode)
if store == nil {
return renderCommandCardTranscript(commandCard{
Title: "Permissions",
Summary: []string{mode + " permissions", "grants unavailable"},
Sections: []commandCardSection{
{
Title: "State",
Fields: []commandField{
{Key: "mode", Value: mode},
},
},
{
Title: "Grants",
Lines: []string{"persistent grants: unavailable"},
},
},
})
}
grants, err := store.List()
if err != nil {
return renderCommandCardTranscript(commandCard{
Title: "Permissions",
Summary: []string{mode + " permissions", "grants error"},
Sections: []commandCardSection{
{
Title: "State",
Fields: []commandField{
{Key: "mode", Value: mode},
},
},
{
Title: "Grants",
Lines: []string{"error: " + err.Error()},
},
},
})
}
prefixes := []sandbox.CommandPrefixGrant{}
if prefixStore, ok := store.(commandPrefixGrantLister); ok {
var prefixErr error
prefixes, prefixErr = prefixStore.ListCommandPrefixes()
if prefixErr != nil {
return renderCommandCardTranscript(commandCard{
Title: "Permissions",
Summary: []string{mode + " permissions", "grants error"},
Sections: []commandCardSection{
{
Title: "State",
Fields: []commandField{
{Key: "mode", Value: mode},
},
},
{
Title: "Grants",
Lines: []string{"error: " + prefixErr.Error()},
},
},
})
}
}
snapshots := zerocommands.SandboxGrantSnapshots(grants)
grantRows := []commandRow{}
if len(snapshots) == 0 && len(prefixes) == 0 {
grantRows = append(grantRows, commandRow{Text: "none"})
} else {
for _, grant := range snapshots {
line := fmt.Sprintf("%s [%s]", grant.ToolName, grant.Decision)
if grant.ApprovedAt != "" {
line += " approved " + grant.ApprovedAt
}
if grant.Reason != "" {
line += " - " + grant.Reason
}
grantRows = append(grantRows, commandRow{Text: line})
}
for _, grant := range prefixes {
line := fmt.Sprintf("%s `%s` [command-prefix]", grant.ToolName, strings.Join(grant.Prefix, " "))
if grant.ApprovedAt != "" {
line += " approved " + grant.ApprovedAt
}
if grant.Reason != "" {
line += " - " + grant.Reason
}
grantRows = append(grantRows, commandRow{Text: line})
}
}
return renderCommandCardTranscript(commandCard{
Title: "Permissions",
Summary: []string{mode + " permissions", formatGrantCount(len(snapshots) + len(prefixes))},
Sections: []commandCardSection{
{
Title: "State",
Fields: []commandField{
{Key: "mode", Value: mode},
},
},
{
Title: "Grants",
Rows: grantRows,
},
},
})
}
// grantLister is the subset of sandbox.GrantStore used by permissionsText().
// It exists to let tests inject error-stub stores without reaching for a real
// filesystem path.
type grantLister interface {
List() ([]sandbox.Grant, error)
}
type commandPrefixGrantLister interface {
ListCommandPrefixes() ([]sandbox.CommandPrefixGrant, error)
}
func formatGrantCount(count int) string {
if count == 0 {
return "no persistent grants"
}
if count == 1 {
return "1 persistent grant"
}
return fmt.Sprintf("%d persistent grants", count)
}
func (m model) providerText() string {
profileLines := []string{
"provider: " + displayValue(m.providerName, "none"),
"model: " + displayValue(m.modelName, "none"),
}
if !config.HasProviderProfile(m.providerProfile) {
profileLines = append(profileLines, "profile: not configured")
return renderCommandOutput(commandOutput{
Title: "Provider",
Status: commandStatusWarning,
Sections: []commandSection{
{Title: "Active", Lines: profileLines},
{Title: "Next actions", Lines: []string{
"zero providers catalog",
"zero providers setup openai --set-active",
"zero providers add openai --api-key-env OPENAI_API_KEY --set-active",
}},
},
})
}
snapshot := zerocommands.ProviderSnapshotFromProfile(m.providerProfile, true)
// A keyless profile backed by a stored OAuth login (e.g. ChatGPT) is fully
// authenticated — without this, /provider status showed a WORKING provider
// as "api key: not set" with a warning, unlike `zero providers list` which
// already fills OAuthLogin.
if !snapshot.APIKeySet {
snapshot.OAuthLogin = oauthLoginAvailable(m.providerProfile)
}
credentialState := apiKeyState(snapshot.APIKeySet)
if !snapshot.APIKeySet && snapshot.OAuthLogin {
credentialState = "oauth login"
}
profileLines = append(profileLines,
"active: "+boolText(snapshot.Active),
"kind: "+displayValue(snapshot.ProviderKind, "unknown"),
"api model: "+displayValue(snapshot.APIModel, "unknown"),
"base url: "+displayValue(snapshot.BaseURL, "default"),
"api key: "+credentialState,
)
if snapshot.Message != "" {
profileLines = append(profileLines, "provider status: "+snapshot.Status+" - "+snapshot.Message)
}
status := commandStatusOK
actionLines := providerNextActionLines(m.providerProfile, snapshot, m.providerName)
if providerCredentialRequired(m.providerProfile, snapshot.ProviderKind) && !providerProfileHasCredential(m.providerProfile) && !snapshot.OAuthLogin {
status = commandStatusWarning
}
return renderCommandOutput(commandOutput{
Title: "Provider",
Status: status,
Sections: []commandSection{
{Title: "Active", Lines: profileLines},
{Title: "Next actions", Lines: actionLines},
},
})
}
func providerNextActionLines(profile config.ProviderProfile, snapshot zerocommands.ProviderSnapshot, activeName string) []string {
providerName := firstProviderDisplayValue(snapshot.Name, activeName, profile.Name, providerSetupCatalogID(profile, snapshot.ProviderKind), "openai")
setupID := providerSetupCatalogID(profile, snapshot.ProviderKind)
lines := []string{}
if providerCredentialRequired(profile, snapshot.ProviderKind) && !providerProfileHasCredential(profile) {
if envName := providerCredentialEnvName(profile, snapshot.ProviderKind); envName != "" {
lines = append(lines,
"set "+envName+" in your environment",
"zero providers add "+setupID+" --api-key-env "+envName+" --set-active",
)
} else {
lines = append(lines, "set provider credentials in your environment")
}
}
return append(lines,
"zero providers check "+providerName+" --connectivity",
"zero providers catalog",
"zero providers setup "+setupID+" --set-active",
)
}
func providerProfileHasCredential(profile config.ProviderProfile) bool {
return profile.HasConfiguredCredential()
}
func providerCredentialRequired(profile config.ProviderProfile, providerKind string) bool {
if descriptor, ok := providerCatalogDescriptor(profile); ok {
// A custom endpoint's RequiresAuth is just the wizard's template
// default, not a fact about this profile's actual endpoint (issue
// #555 follow-up) — only an explicit, non-default APIKeyEnv means
// this saved profile itself expects a credential.
if descriptor.Custom {
envVar := strings.TrimSpace(profile.APIKeyEnv)
return envVar != "" && envVar != firstProviderDisplayValue(descriptor.AuthEnvVars...)
}
return descriptor.RequiresAuth
}
switch config.ProviderKind(strings.TrimSpace(providerKind)) {
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible, config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat, config.ProviderKindGoogle:
return true
default:
return false
}
}
func providerCredentialEnvName(profile config.ProviderProfile, providerKind string) string {
if envName := strings.TrimSpace(profile.APIKeyEnv); envName != "" {
return envName
}
if descriptor, ok := providerCatalogDescriptor(profile); ok && len(descriptor.AuthEnvVars) > 0 {
return descriptor.AuthEnvVars[0]
}
switch config.ProviderKind(strings.TrimSpace(providerKind)) {
case config.ProviderKindOpenAI, config.ProviderKindOpenAICompatible:
return "OPENAI_API_KEY"
case config.ProviderKindAnthropic, config.ProviderKindAnthropicCompat:
return "ANTHROPIC_API_KEY"
case config.ProviderKindGoogle:
return "GEMINI_API_KEY"
default:
return ""
}
}
func providerSetupCatalogID(profile config.ProviderProfile, providerKind string) string {
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" {
return catalogID
}
switch config.ProviderKind(strings.TrimSpace(providerKind)) {
case config.ProviderKindOpenAI:
return "openai"
case config.ProviderKindAnthropic:
return "anthropic"
case config.ProviderKindGoogle:
return "google"
case config.ProviderKindOpenAICompatible:
return "custom-openai-compatible"
case config.ProviderKindAnthropicCompat:
return "custom-anthropic-compatible"
default:
return firstProviderDisplayValue(profile.Name, "openai")
}
}
func providerCatalogDescriptor(profile config.ProviderProfile) (providercatalog.Descriptor, bool) {
catalogID := strings.TrimSpace(profile.CatalogID)
if catalogID == "" {
return providercatalog.Descriptor{}, false
}
descriptor, err := providercatalog.Require(catalogID)
if err != nil {
return providercatalog.Descriptor{}, false
}
return descriptor, true
}
func firstProviderDisplayValue(values ...string) string {
for _, value := range values {
value = strings.TrimSpace(value)
if value != "" {
return value
}
}
return ""
}
func (m model) modelText(args string) string {
return renderCommandOutput(commandOutput{
Title: "Model",
Status: commandStatusOK,
Sections: []commandSection{{
Title: "Active",
Lines: []string{
"model: " + displayValue(m.modelName, "none"),
"provider: " + displayValue(m.providerName, "none"),
"effort: " + m.effortDisplay(),
},
}},
Hints: []string{"use /model list to inspect models or /model <id> to switch this TUI session"},
})
}
// avgTurnLatencyText reports the session's rolling average turn wall-time for
// /context — the "is it slow?" signal a user otherwise can only feel. "n/a" until
// a turn has completed.
func (m model) avgTurnLatencyText() string {
if m.turnLatencyCount == 0 {
return "n/a"
}
avgSeconds := m.turnLatencySum.Seconds() / float64(m.turnLatencyCount)
if m.turnTTFTCount > 0 {
ttftSeconds := m.turnTTFTSum.Seconds() / float64(m.turnTTFTCount)
return fmt.Sprintf("%.1fs avg (%.1fs to first token, %d turns)", avgSeconds, ttftSeconds, m.turnLatencyCount)
}
return fmt.Sprintf("%.1fs avg (%d turns)", avgSeconds, m.turnLatencyCount)
}
func (m model) contextText() string {
toolCount := len(m.registeredTools())
return renderCommandCardTranscript(commandCard{
Title: "Context",
Summary: []string{
"go runtime",
string(m.permissionMode) + " permissions",
pluralizeCount(toolCount, "tool", "tools"),
},
Sections: []commandCardSection{
{
Title: "Runtime",
Fields: []commandField{
{Key: "cwd", Value: displayValue(m.cwd, "unknown")},
{Key: "provider", Value: displayValue(m.providerName, "none")},
{Key: "model", Value: displayValue(m.modelName, "none")},
{Key: "effort", Value: m.effortDisplay()},
{Key: "style", Value: displayValue(m.responseStyle, defaultResponseStyle)},
{Key: "usage", Value: m.usageSummaryText()},
{Key: "cache", Value: m.cacheEfficiencyText()},
{Key: "latency", Value: m.avgTurnLatencyText()},
{Key: "max turns", Value: fmt.Sprint(m.agentOptions.MaxTurns)},
},
},
{
Title: "Session",
Fields: []commandField{
{Key: "active", Value: displayValue(m.activeSession.SessionID, "none")},
{Key: "root", Value: displayValue(m.sessionRootDir(), "unknown")},
{Key: "compaction", Value: contextCompactionStatus(m.compactionStatus())},
},
},
{
Title: "Tools",
Fields: []commandField{
{Key: "registered", Value: fmt.Sprint(toolCount)},
},
},
},
Actions: []string{"/permissions manage access", "/tools inspect catalog"},
})
}
func (m model) registeredTools() []tools.Tool {
if m.registry == nil {
return nil
}
return m.registry.All()
}
func (m model) sessionRootDir() string {
if m.sessionStore == nil {
return ""
}
return m.sessionStore.RootDir
}
func pluralizeCount(count int, singular string, plural string) string {
label := plural
if count == 1 {
label = singular
}
return fmt.Sprintf("%d %s", count, label)
}
func contextCompactionStatus(status string) string {
if status == "not compacted" {
return "idle"
}
return status
}
// onOff renders a boolean preference as "on"/"off" for config display.
func onOff(v bool) string {
if v {
return "on"
}
return "off"
}
func (m model) configText() string {
return renderCommandOutput(commandOutput{
Title: "Config",
Status: commandStatusOK,
Sections: []commandSection{
{
Title: "Runtime",
Lines: []string{
"runtime: go",
fmt.Sprintf("max turns: %d", m.agentOptions.MaxTurns),
"permission mode: " + string(m.permissionMode),
"recaps: " + onOff(m.recapsEnabled),
},
},
{
Title: "Provider",
Lines: []string{
"provider: " + displayValue(m.providerName, "none"),
"model: " + displayValue(m.modelName, "none"),
"api key: " + apiKeyState(strings.TrimSpace(m.providerProfile.APIKey) != "" || m.providerProfile.APIKeyStored),
},
},
},
})
}
func (m model) debugText() string {
state := "idle"
if m.pending {
state = "running"
}
return renderCommandOutput(commandOutput{
Title: "Debug",
Status: commandStatusInfo,
Sections: []commandSection{{
Title: "Runtime",
Lines: []string{
"run state: " + state,
"active run: " + fmt.Sprint(m.activeRunID),
"pending permission: " + boolText(m.pendingPermission != nil),
},
}},
})
}
// skillsText is the /skills fallback when NO skills are installed — an install
// hint. With skills present /skills opens the searchable skill picker instead
// (see newSkillPicker), matching how /model works.
func (m model) skillsText() string {
return renderCommandOutput(commandOutput{
Title: "Skills",
Status: commandStatusInfo,
Sections: []commandSection{{
Lines: []string{"No skills installed."},
}},
Hints: []string{
"install one: create <skills-dir>/<name>/SKILL.md (see `zero skills`)",
},
})
}