mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 05:01:28 -10:00
* feat(providers): OAuth login profiles and list-first /provider manager Make multiple providers a first-class workflow: every OAuth login is now a visible, switchable provider, and /provider manages the whole lifecycle. OAuth logins become provider profiles: - zero auth chatgpt / zero auth login <preset> and all four TUI login paths (wizard + first-run, browser + device-code) now upsert a catalog-backed profile via config.EnsureCatalogProvider after storing the token — without stealing the active provider. Previously the token was stored invisibly: the provider showed up nowhere and 'providers use' failed not-found. - Switching to an OAuth provider no longer inlines the bearer as an API key, which suppressed the runtime resolver (no token refresh, no Codex chatgpt-account-id header → guaranteed 401s). The credential gate now recognizes stored OAuth logins on keyless profiles. - providers list / current report truthful credential state: stored keys count as configured (APIKeySet honors apiKeyStored) and token logins show 'oauth login'; providers check accepts an OAuth login as a credential. List-first /provider manager (TUI): - /provider opens the saved-provider list: Enter activates via the shared switch path, 'a' adds (existing wizard), 'e' opens a field-level editor (endpoint, model, key, description, and safe rename — the stored key, activeProvider, and live ZERO_PROVIDER follow the new name), 'd' deletes with an inline confirm and stored-key cleanup. Esc walks back one level. - Credential states resolve asynchronously so keychain reads never block the render loop; 'stored key missing' surfaces broken keychain entries. - Implemented as manager steps on providerWizardState so every existing overlay gate, key route, and mouse hook covers it unchanged. CLI + config parity: - zero providers remove <name> (deletes the stored key, hands the active pointer off) and zero providers rename <old> <new>. - config.RemoveProvider / RenameProvider: read-modify-atomic-write; rename migrates the credential-store entry write-new-then-delete-old so an interruption can never lose the key. * fix(providers): address PR #560 review findings All six review findings validated as real; fixes with regression tests: - Persist the apiKeyStored marker when a key is captured onto an existing profile: mergeProfile deliberately ignores the marker (a project config must not claim the user's stored keys at resolve time), so the user-config WRITE path (UpsertProvider) now re-applies it — previously editing a key onto an env/no-key profile stranded the secret in the credential store while every ApplyStoredAPIKey gate skipped it. [jatmn P2] - Let active-pointer recovery select OAuth-login providers: firstUsableProvider now applies the same providerHasOAuthLogin check as setupRequired/usableSavedProviders, so a stale activeProvider plus a saved ChatGPT/xAI login recovers instead of forcing onboarding. [jatmn P2] - Delete stored keys from the store BESIDE the config being edited: providers remove and the TUI manager delete now use ProviderKeyStoreAt (matching setup/rename) instead of the default-path store. [jatmn P2] - Roll back the key migration when RenameProvider's config write fails, so the store and config.json can't desync (key under the new name, config still naming the old one). [CodeRabbit] - Clear provider descriptions for real: new SetProviderDescription writes the field verbatim (the upsert merge treats empty as unchanged); the manager's editor routes description changes through it. [jatmn P3] - Surface stored-key deletion failures in providers remove (stderr warning + keyError in the JSON payload) and the TUI delete status, instead of letting a lingering secret read as a clean removal. [CodeRabbit] * fix(providers): second review round — case-only rename, logout hint, test hermeticity - Skip the credential-store migration on case-only renames (groq -> Groq): the store normalizes names case-insensitively, so Set(new)+Delete(old) target ONE entry and the delete removed the key that was just "moved". The existing entry already serves the new name. [CodeRabbit] - Point the delete flow's cleanup hint at the stored login that actually exists: after a rename ({name:"codex", catalogID:"chatgpt"}) the token lives under the catalog id, and `zero auth logout codex` would delete nothing. oauthLoginName resolves the same FirstStored candidate the runtime uses. [jatmn P3] - Lock in APIKeyEnv semantics after a key edit: the env reference survives as an explicit override (a SET env var wins at resolve time; the stored key applies when it is absent). [CodeRabbit nitpick] - Make main's new firstUsableProvider tests hermetic: they read the REAL OAuth token store, so any developer with an actual xai/chatgpt login gets spurious failures (found because this machine has one). Also rebased onto main; the only conflict was comment wording in firstUsableProvider, where the audit commit (#481) independently landed the same OAuth-login fallback check this branch added. * fix(tui): read stored keys from the active config's credential store The TUI's write paths (SecureProviderProfile capture, rename migration, manager delete) all target the store beside m.userConfigPath, but the read paths still opened the DEFAULT-path store: profileWithCredential (feeding the /model and manager switch gate), the model picker's discovery credential lookup, and the manager's async credential-status probe. With a non-default config path, a provider saved with only apiKeyStored had its key in the config-adjacent store, so the gate rejected it as having no usable credential and the manager showed 'stored key missing' for a healthy profile. All TUI stored-key reads now resolve through providerKeyStoreForPath (config-adjacent, default-path fallback for ephemeral sessions), so the UI reads from the same store it writes to. Regression test covers both the credential probe and the switch path with a key seeded only beside the config. Also consolidates the OAuth test-store seeding on withAuthStore (now pins ZERO_OAUTH_STORAGE=file so an inherited keyring backend can't bypass the temp path) per review nitpick. * fix(providers): resolve self-review findings — atomic edits, key normalization, threading Findings from a multi-agent code review of this branch (8 finder angles, independently verified); all confirmed against the code, several with empirical repros. Correctness: - config.EditProvider: one atomic read-modify-write for the manager's Save (rename + fields + verbatim description + stored-key marker + key migration with write-failure rollback). Replaces the rename→upsert→ describe sequence that (a) turned a case-only rename (groq → Groq) into a DUPLICATE profile via UpsertProvider's case-sensitive merge, and (b) could fail halfway, leaving config.json renamed while savedProviders and ZERO_PROVIDER still held the old name (retry then failed 'not found'). A rename now also migrates a freshly captured replacement key (captured under the current name; the migration moves it). - savedProviders is mutated surgically on manager delete/edit instead of being replaced with the raw user-config list — the session list is seeded from the RESOLVED (project-config layered) and usability-FILTERED set, so wholesale replacement dropped project-contributed providers and resurrected filtered stubs for the rest of the session. - oauth.ProviderKey normalizes provider names to lower case at the single write/lookup choke point: `zero auth login xAI` stored provider:xAI while every candidate lookup was case-sensitive — a successful login invisible to the runtime. Map-based presence checks lowercase to match. - OAuth login persistence moved onto the Update goroutine (message handlers) — a background tea.Cmd (device-code polls land minutes later) doing an unlocked config read-modify-write raced UI-thread writes (lost updates). The write error is now surfaced and the in-memory mirror is skipped on failure, so a profile that never reached disk can't show as a working provider that vanishes on restart. UX / consistency: - Esc inside a manager-launched add flow walks back to the provider list from EVERY step (was: first step only; deeper steps destroyed the overlay and the user's place). In-flight OAuth logins are invalidated on the way out; footers say 'Esc back'. - switchProviderModel returns a structured outcome; the manager branches on it instead of strings.Contains(text, "Switched to") — UI copy is no longer a control-flow contract (a provider named 'Switched to prod' could close the manager on a refusal). - Manager delete's keychain subprocess + OAuth lookup run in a follow-up tea.Cmd (the confirm keypress no longer blocks the render loop); the credential probe takes ONE token-store snapshot instead of 2 reads per row, and resolved states carry over across reloads (no 'checking…' flash). - Credential-state reporting converges: /provider status recognizes OAuth logins (was 'api key: not set' + warning for a WORKING provider), doctor uses the shared HasConfiguredCredential definition plus OAuth awareness (its 'matching ProviderSnapshot.APIKeySet' claim is true again), and the manager's classifier falls through a stale stored-key marker to env/OAuth the way the runtime does. * Fix provider manager test formatting
534 lines
16 KiB
Go
534 lines
16 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/modelregistry"
|
|
"github.com/Gitlawb/zero/internal/zerocommands"
|
|
)
|
|
|
|
type commandCenterOptions struct {
|
|
json bool
|
|
provider string
|
|
transport string
|
|
includeDeprecated bool
|
|
}
|
|
|
|
type configSummary = zerocommands.ConfigSnapshot
|
|
type providerSummary = zerocommands.ProviderSnapshot
|
|
type modelSummary = zerocommands.ModelSnapshot
|
|
type providerCatalogSummary = zerocommands.ProviderCatalogSnapshot
|
|
|
|
func runConfig(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, help, err := parseCommandCenterArgs(args, false, false)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeConfigHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
return exitCode
|
|
}
|
|
summary := summarizeConfig(resolved)
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, summary); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, formatConfigSummary(summary)); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runProviders(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
command := "list"
|
|
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
|
|
command = strings.ToLower(strings.TrimSpace(args[0]))
|
|
args = args[1:]
|
|
}
|
|
if command == "help" {
|
|
if err := writeProvidersHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if command == "add" {
|
|
return runProvidersAdd(args, stdout, stderr, deps)
|
|
}
|
|
if command == "check" {
|
|
return runProvidersCheck(args, stdout, stderr, deps)
|
|
}
|
|
if command == "use" {
|
|
return runProvidersUse(args, stdout, stderr, deps)
|
|
}
|
|
if command == "remove" || command == "rm" {
|
|
return runProvidersRemove(args, stdout, stderr, deps)
|
|
}
|
|
if command == "rename" {
|
|
return runProvidersRename(args, stdout, stderr, deps)
|
|
}
|
|
if command == "setup" {
|
|
return runProvidersSetup(args, stdout, stderr, deps)
|
|
}
|
|
if command == "detect" {
|
|
return runProvidersDetect(args, stdout, stderr, deps)
|
|
}
|
|
if command == "models" {
|
|
return runProvidersModels(args, stdout, stderr, deps)
|
|
}
|
|
if command != "list" && command != "current" && command != "catalog" {
|
|
return writeExecUsageError(stderr, fmt.Sprintf("unknown providers command %q", command))
|
|
}
|
|
options, help, err := parseCommandCenterArgs(args, false, command == "catalog")
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeProvidersHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if command == "catalog" {
|
|
catalog, err := listProviderCatalogSummaries(options)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, map[string]any{"providers": catalog}); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, formatProviderCatalogSummaries(catalog)); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
resolved, exitCode := resolveCommandCenterConfig(stderr, deps)
|
|
if exitCode != exitSuccess {
|
|
return exitCode
|
|
}
|
|
summary := summarizeConfig(resolved)
|
|
providers := summary.Providers
|
|
if command == "current" {
|
|
providers = []providerSummary{}
|
|
for _, provider := range summary.Providers {
|
|
if provider.Active {
|
|
providers = append(providers, provider)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if options.json {
|
|
if command == "current" {
|
|
var provider any
|
|
if len(providers) > 0 {
|
|
provider = providers[0]
|
|
}
|
|
if err := writePrettyJSON(stdout, map[string]any{"provider": provider}); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if err := writePrettyJSON(stdout, map[string]any{"providers": providers}); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, formatProviderSummaries(command, providers)); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func runModels(args []string, stdout io.Writer, stderr io.Writer) int {
|
|
if len(args) > 0 && (args[0] == "list" || args[0] == "ls") {
|
|
args = args[1:]
|
|
}
|
|
options, help, err := parseCommandCenterArgs(args, true, false)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeModelsHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
registry, err := modelregistry.DefaultRegistry()
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), exitCrash)
|
|
}
|
|
models, err := listModelSummaries(registry, options)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, map[string]any{"models": models}); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, formatModelSummaries(models)); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
func resolveCommandCenterConfig(stderr io.Writer, deps appDeps) (config.ResolvedConfig, int) {
|
|
workspaceRoot, err := resolveWorkspaceRoot("", deps)
|
|
if err != nil {
|
|
return config.ResolvedConfig{}, writeExecUsageError(stderr, err.Error())
|
|
}
|
|
resolved, err := deps.resolveConfig(workspaceRoot, config.Overrides{})
|
|
if err != nil {
|
|
return config.ResolvedConfig{}, writeAppError(stderr, err.Error(), exitProvider)
|
|
}
|
|
return resolved, exitSuccess
|
|
}
|
|
|
|
func parseCommandCenterArgs(args []string, allowModelFilters bool, allowProviderCatalogFilters bool) (commandCenterOptions, bool, error) {
|
|
options := commandCenterOptions{}
|
|
for index := 0; index < len(args); index++ {
|
|
arg := args[index]
|
|
switch {
|
|
case arg == "-h" || arg == "--help" || arg == "help":
|
|
return options, true, nil
|
|
case arg == "--json":
|
|
options.json = true
|
|
case allowModelFilters && arg == "--include-deprecated":
|
|
options.includeDeprecated = true
|
|
case allowModelFilters && arg == "--provider":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.provider = value
|
|
index = next
|
|
case allowModelFilters && strings.HasPrefix(arg, "--provider="):
|
|
value, err := requiredInlineFlagValue(arg, "--provider")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.provider = value
|
|
case allowProviderCatalogFilters && arg == "--transport":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.transport = value
|
|
index = next
|
|
case allowProviderCatalogFilters && strings.HasPrefix(arg, "--transport="):
|
|
value, err := requiredInlineFlagValue(arg, "--transport")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.transport = value
|
|
case strings.HasPrefix(arg, "-"):
|
|
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
|
default:
|
|
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
|
}
|
|
}
|
|
return options, false, nil
|
|
}
|
|
|
|
func summarizeConfig(resolved config.ResolvedConfig) configSummary {
|
|
summary := zerocommands.ConfigSnapshotFromResolved(resolved)
|
|
// Mark keyless profiles that authenticate via a stored OAuth login (e.g.
|
|
// ChatGPT) so the list shows "oauth login" instead of "api key: not set" —
|
|
// the same candidate matching the runtime resolver uses.
|
|
logins := oauthLoggedInProviders()
|
|
if len(logins) > 0 {
|
|
for index := range summary.Providers {
|
|
for _, profile := range resolved.Providers {
|
|
if profile.Name == summary.Providers[index].Name {
|
|
summary.Providers[index].OAuthLogin = providerHasOAuthLogin(profile, logins)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sort.SliceStable(summary.Providers, func(i int, j int) bool {
|
|
if summary.Providers[i].Active != summary.Providers[j].Active {
|
|
return summary.Providers[i].Active
|
|
}
|
|
return summary.Providers[i].Name < summary.Providers[j].Name
|
|
})
|
|
return summary
|
|
}
|
|
|
|
func listModelSummaries(registry modelregistry.Registry, options commandCenterOptions) ([]modelSummary, error) {
|
|
summaries, err := zerocommands.ModelSnapshots(registry, zerocommands.ModelSnapshotOptions{
|
|
Provider: modelregistry.ProviderKind(strings.TrimSpace(strings.ToLower(options.provider))),
|
|
IncludeDeprecated: options.includeDeprecated,
|
|
})
|
|
if err != nil {
|
|
return nil, execUsageError{err.Error()}
|
|
}
|
|
sort.SliceStable(summaries, func(i int, j int) bool {
|
|
if summaries[i].Provider == summaries[j].Provider {
|
|
return summaries[i].ID < summaries[j].ID
|
|
}
|
|
return summaries[i].Provider < summaries[j].Provider
|
|
})
|
|
return summaries, nil
|
|
}
|
|
|
|
func listProviderCatalogSummaries(options commandCenterOptions) ([]providerCatalogSummary, error) {
|
|
summaries, err := zerocommands.ProviderCatalogSnapshots(zerocommands.ProviderCatalogSnapshotOptions{
|
|
Transport: options.transport,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sort.SliceStable(summaries, func(i int, j int) bool {
|
|
if summaries[i].Recommended != summaries[j].Recommended {
|
|
return summaries[i].Recommended
|
|
}
|
|
return summaries[i].ID < summaries[j].ID
|
|
})
|
|
return summaries, nil
|
|
}
|
|
|
|
func formatConfigSummary(summary configSummary) string {
|
|
lines := []string{
|
|
"Config",
|
|
"runtime: " + summary.Runtime,
|
|
"active provider: " + displayCLIValue(summary.ActiveProvider, "none"),
|
|
fmt.Sprintf("max turns: %d", summary.MaxTurns),
|
|
"providers:",
|
|
}
|
|
if len(summary.Providers) == 0 {
|
|
lines = append(lines, " (none)")
|
|
}
|
|
for _, provider := range summary.Providers {
|
|
lines = append(lines, " "+formatProviderLine(provider))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func formatProviderSummaries(command string, providers []providerSummary) string {
|
|
title := "Providers"
|
|
if command == "current" {
|
|
title = "Provider"
|
|
}
|
|
lines := []string{title}
|
|
if len(providers) == 0 {
|
|
lines = append(lines, " (none)")
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
for _, provider := range providers {
|
|
if command == "current" {
|
|
lines = append(lines,
|
|
"name: "+displayCLIValue(provider.Name, "none"),
|
|
"kind: "+displayCLIValue(provider.ProviderKind, "unknown"),
|
|
"model: "+displayCLIValue(provider.Model, "none"),
|
|
"api model: "+displayCLIValue(provider.APIModel, "unknown"),
|
|
"base url: "+displayCLIValue(provider.BaseURL, "default"),
|
|
"api key: "+providerCredentialState(provider),
|
|
)
|
|
if provider.Message != "" {
|
|
lines = append(lines, "status: "+provider.Status+" - "+provider.Message)
|
|
}
|
|
continue
|
|
}
|
|
lines = append(lines, " "+formatProviderLine(provider))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func formatProviderLine(provider providerSummary) 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))
|
|
if provider.Message != "" {
|
|
line += " (" + provider.Status + ": " + provider.Message + ")"
|
|
}
|
|
return line
|
|
}
|
|
|
|
func formatProviderCatalogSummaries(providers []providerCatalogSummary) string {
|
|
lines := []string{"Provider Catalog"}
|
|
if len(providers) == 0 {
|
|
lines = append(lines, " (none)")
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
for _, provider := range providers {
|
|
lines = append(lines, formatProviderCatalogLine(provider))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func formatProviderCatalogLine(provider providerCatalogSummary) string {
|
|
name := formatProviderCatalogValue(provider.Name, "unknown")
|
|
prefix := " id="
|
|
if provider.Recommended {
|
|
prefix = " ★ id="
|
|
name += " (recommended)"
|
|
}
|
|
lines := []string{
|
|
fmt.Sprintf("%s%s name=%s",
|
|
prefix,
|
|
formatProviderCatalogValue(provider.ID, "unknown"),
|
|
name,
|
|
),
|
|
fmt.Sprintf(" transport=%s defaultModel=%s",
|
|
formatProviderCatalogValue(provider.Transport, "unknown"),
|
|
formatProviderCatalogValue(provider.DefaultModel, "none"),
|
|
),
|
|
" defaultBaseURL=" + formatProviderCatalogValue(provider.DefaultBaseURL, "none"),
|
|
}
|
|
if provider.RequiresAuth {
|
|
lines = append(lines, " authEnvVars="+formatProviderCatalogValue(strings.Join(provider.AuthEnvVars, ","), "none"))
|
|
}
|
|
lines = append(lines, fmt.Sprintf(" requiresAuth=%t local=%t runtimeSupported=%t",
|
|
provider.RequiresAuth,
|
|
provider.Local,
|
|
provider.RuntimeSupported,
|
|
))
|
|
if provider.RuntimeSupported {
|
|
lines = append(lines, " setup: zero providers setup "+displayCLIValue(provider.ID, "unknown")+" --set-active")
|
|
} else {
|
|
lines = append(lines, " unsupported: "+displayCLIValue(provider.RuntimeUnsupportedReason, "unknown"))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func formatModelSummaries(models []modelSummary) string {
|
|
lines := []string{"Models"}
|
|
if len(models) == 0 {
|
|
lines = append(lines, " (none)")
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
for _, model := range models {
|
|
lines = append(lines, fmt.Sprintf(" %s [%s] ctx=%d out=%d - %s", model.ID, model.Provider, model.ContextWindow, model.MaxOutputTokens, model.DisplayName))
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func apiKeyState(set bool) string {
|
|
if set {
|
|
return "set"
|
|
}
|
|
return "not set"
|
|
}
|
|
|
|
// providerCredentialState renders the credential column: a configured key wins,
|
|
// then a stored OAuth login, then "not set".
|
|
func providerCredentialState(provider providerSummary) string {
|
|
if !provider.APIKeySet && provider.OAuthLogin {
|
|
return "oauth login"
|
|
}
|
|
return apiKeyState(provider.APIKeySet)
|
|
}
|
|
|
|
func displayCLIValue(value string, fallback string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func formatProviderCatalogValue(value string, fallback string) string {
|
|
value = displayCLIValue(value, fallback)
|
|
for _, r := range value {
|
|
if unicode.IsSpace(r) || unicode.IsControl(r) || r == '"' {
|
|
return strconv.Quote(value)
|
|
}
|
|
}
|
|
return value
|
|
}
|
|
|
|
func writeConfigHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero config [flags]
|
|
|
|
Inspects resolved Go configuration without printing secrets.
|
|
|
|
Flags:
|
|
--json Print JSON summary
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeModelsHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero models list [flags]
|
|
|
|
Lists Zero model registry entries.
|
|
|
|
Flags:
|
|
--json Print JSON model list
|
|
--provider <provider> Filter by openai, anthropic, google, or openai-compatible
|
|
--include-deprecated Include deprecated models
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|
|
|
|
func writeProvidersHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero providers current [flags]
|
|
zero providers list [flags]
|
|
zero providers catalog [flags]
|
|
zero providers add <catalog-id> [flags]
|
|
zero providers check [name] [flags]
|
|
zero providers use <name> [flags]
|
|
zero providers remove <name> [flags]
|
|
zero providers rename <old> <new> [flags]
|
|
zero providers setup <catalog-id> [flags]
|
|
zero providers detect [flags]
|
|
zero providers models [name] [flags]
|
|
|
|
Inspects resolved provider profiles and provider catalog descriptors without printing secrets.
|
|
Detect probes for running local runtimes (Ollama, LM Studio) and prints adopt commands plus per-provider next steps.
|
|
Models probes a provider's live model-listing endpoint (e.g. an OpenAI-compatible /v1/models) and lists the models it serves — including custom OpenAI-/Anthropic-compatible endpoints — so a self-hosted provider needs no per-model config.
|
|
|
|
Flags:
|
|
--json Print JSON summary
|
|
--connectivity Probe provider endpoint for providers check
|
|
--transport <transport> Filter catalog descriptors by transport
|
|
|
|
Add flags:
|
|
--name <name> Saved provider profile name
|
|
--model <model> Override catalog default model
|
|
--base-url <url> Override catalog default base URL
|
|
--api-key-env <name> Environment variable that contains the API key
|
|
--auth-header <header> Custom API-key header name
|
|
--auth-scheme <scheme> Auth scheme prefix, for example Bearer or Token
|
|
--auth-header-value <v> Exact auth header value; stored in config
|
|
--header <key=value> Custom provider header; repeatable
|
|
--set-active Make the added provider active
|
|
|
|
Setup flags:
|
|
--name <name> Planned provider profile name
|
|
--model <model> Planned model override
|
|
--base-url <url> Planned base URL override
|
|
--api-key-env <name> Planned API key environment variable
|
|
--set-active Include --set-active in the add command
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|