Files
zero/internal/cli/command_center.go
Gaurav Bhatia 7f8e5f08bb feat(notify): make permission-prompt alerts on by default and discoverable (#1001)
* feat(notify): make permission-prompt alerts on by default and discoverable

The notify system existed but was silent unless the user hand-edited
config.json, with no UI surface to discover or change it.

- resolver: fall back to mode=both, focusMode=unfocused when the notify
  block is missing or empty (Fixes #579)
- tui: add /notify slash command with popup picker, mirroring /theme;
  explicit choices persist via config.SetNotify
- cli: add `zero config notify` to read/update/reset the preference
  (--mode, --focus, --reset, --json)
- config: add SetNotify writer using the existing atomic-write helper,
  validating against the same vocab the resolver accepts

The TUI effectiveTUINotifyMode default (empty -> both) now matches the
resolver. exec_test.go seeds notify.mode=off where a test asserted
silent stderr, which the old empty-default implicitly provided.

* fix(notify): apply review feedback from CodeRabbit

- cli: omitted --mode/--focus flags now preserve the current resolved
  value instead of wiping it (--reset remains the only clearing path);
  aligns the CLI with the TUI's mode-only preservation behavior
- tui: reject /notify inputs with more than two tokens instead of
  silently accepting them
- tui: apply /notify choices to the live notifier via the new
  notify.Notifier.Configure, so the change takes effect on the next
  permission prompt in the same session (the previous message claimed
  this but only the persisted value was updated)
- notify: add Notifier.Configure (mutex-guarded policy swap that
  preserves sinks, focus state, and the writer)
- tests: mode-only/focus-only CLI preservation, live-notifier apply,
  trailing-argument rejection, Configure immediate-effect + sink
  retention

* fix(notify): read cfg under the lock in Notify

Configure (334c688) made cfg mutable at runtime, but Notify still read
n.cfg.Mode before acquiring n.mu — a data race with a concurrent
Configure. Move the mode check inside the critical section and copy cfg
to a local for all reads.

Regression test TestConfigureConcurrentWithNotify runs Configure
concurrently with Notify; verified it reports DATA RACE on the unfixed
code and passes after the fix (go test -race -count=5).

* fix(notify): keep defaults out of the resolver; preserve the user's own values

Addresses the maintainer review (Vasanthdev2004) on PR #1001. All three
findings share one root cause: the resolved config was treated as if it
were the user's choice.

- resolver: no longer defaults notify.mode/focusMode. The TUI's
  effectiveTUINotifyMode already maps empty -> both on its own, so the
  permission-prompt alert still works out of the box, while headless
  `zero exec` stays byte-identical to base (no BEL/OSC-9 on stderr under
  -o json), the exec empty-stderr fixture is restored, and the
  ZERO_NOTIFY_WEBHOOK_URL sink is not armed by an implicit default.
- cli: `zero config notify` seeds omitted fields from the user's own
  file (new config.UserNotify), never from the resolved view — a
  project config's mode:off can no longer be copied into the user's
  global config, and blank stays blank instead of pinning today's
  default as an explicit choice. --reset remains the only clearing path.
- tui: /notify mode-only changes preserve the focus stored in the
  user's own file (blank stays blank); the /notify picker enumerates the
  full 4x3 mode x focus space so every valid pair is a row, the current
  pair is always preselected, and Enter can never commit a setting the
  user did not choose. State view reads the stored pair.
- tests: the three regressions from the review — exec writes nothing to
  stderr on a clean run (fixture restored + resolver empty-default
  test), a ProjectConfigPath test proving project notify cannot leak
  into the user file, and Enter on an open picker from a pair outside
  the old curated list (off, always) keeps the setting unchanged.

* fix(cli): zero config notify no longer requires a configured provider

The command resolved the full config (providers included) before
touching notification settings, so a fresh user with no provider hit
ErrNoActiveProvider and could not read, set, or reset their notify
preference — the exact first-run user this feature targets (CodeRabbit
review, PR #1001).

The command manages a user preference, so it now talks only to the
user's own config file (config.UserNotify / config.SetNotify) and never
runs config resolution. Display reports the user's stored values — a
project config that overrides notify for one repo is not shown here,
matching the write path's source-of-truth from the maintainer review.

* fix(notify): separate live session policy from persisted user preference

Addresses the review from jatmn (PR #1001). The three notification
states — stored user file, resolved startup pair, live session — are
distinct contracts and must not be conflated:

- handleNotifyCommand now keeps two focus values per update: the LIVE
  focus (in-session, initialized from the resolved pair, so a project's
  focus rule keeps applying) feeds the notifier's Configure and the
  status line; the PERSISTED focus seeds from the user's own file
  (config.UserNotify), so a mode-only /notify writes the mode while the
  user's focus value — blank included — is preserved and project values
  never leak into the global file.
- /notify list reports the live pair (model fields), not the stored
  file, which can legitimately disagree under a project override.
- the /notify picker preselects the live pair; committing a row is an
  explicit choice, so Enter on the preselected row keeps the setting.
- /notify list requires exactly one token, matching /theme, /effort,
  and /style (rejects "/notify list junk").
- shell completions: config gained a notify child with its flags
  (--mode/--focus/--reset/--json), plus a completion-tree assertion for
  both contexts so new CLI surfaces cannot go stale.
- help/copy pass: the CLI help now describes the stored global
  notification preference controlling BOTH the completion and
  needs-input alerts, --reset as returning the TUI to its effective
  default (headless unconfigured stays silent), lists all three focus
  values in /notify usage, and drops the stale "resolver default"
  wording from effectiveTUINotifyMode and the command doc.
- tests: contract-matrix coverage with the three states deliberately
  disagreeing (blank user file + project-resolved off/focused) asserting
  list/picker/preselection/Enter, mode-only and explicit-pair updates on
  both live and persisted sides, and restart precedence; the SA4006
  lint hits are resolved by asserting the returned live model.

lint-static: 0 issues. Affected suites green under -race; the only
remaining local failures are the known environment flakes
(TestRunAuthOpenRouterSavesMintedKey on macOS keychain under a
sanitized env, and TestLoadProviderCommand* 5s subprocess timeouts
under full-suite load — both pass in isolation and fail identically on
clean origin/main here).

* fix(notify): close the remaining review findings (isolation, races, fidelity)

Addresses the third review round (PR #1001) as one boundary pass:

- BTW isolation: /notify mutations and the picker are now behind the
  existing btwCommandUnavailable guard (like /theme), because the side
  conversation shares the parent's notifier pointer — an unblocked
  /notify off inside BTW reconfigured the parent and wrote the global
  file while only the side's display changed. /notify list stays
  available. Regression: side-conversation attempt then return, checking
  the parent's actual notification output and the stored preference.
- Serialization: new UpdateNotify runs read-merge-write as ONE
  transaction under a cross-process advisory lock (flock/LockFileEx on a
  .lock file beside the config, credstore's pattern), so two concurrent
  partial updates (--mode off vs --focus always) cannot interleave and
  silently undo each other's explicit change. The CLI and the TUI's
  mode-only persistence both go through it; the TUI's stale pre-read of
  the stored focus is gone (the merge reads under the lock). Verified
  0/30 lost updates driving the real binary with two concurrent
  processes per round; a goroutine-level regression test asserts both
  explicit fields survive.
- Fidelity: notification writes now edit ONLY the notify member's bytes
  (setNotifyJSONObject, on the existing byte-preserving JSON editor),
  so unrelated values survive with their explicit presence intact —
  including tools.deferThreshold: 0 and MCP disabled: false, which the
  typed serializer's omitempty cannot round-trip, and keys FileConfig
  does not model at all. A reset removes the notify member instead of
  leaving a {} husk.
- Startup recovery: Resolve's ErrNoActiveProvider partial result now
  carries the parsed non-provider fields (notify, sandbox, tools, ...),
  and the setup-recovery branch that clears the resolved config keeps
  the notify block — a stored explicit mode:off no longer resurrects
  alerts through the provider-recovery path. Regression: stale
  activeProvider + stored off/always through recovery asserts the
  launched TUI receives the opt-out, not the both/unfocused default.
- docs nits: zero config --help now describes both alert classes; the
  two remaining "resolver default" wordings updated.

lint-static: 0 issues. Affected packages green under -race (the only
local failure is the known TestRunAuthOpenRouterSavesMintedKey macOS
keychain flake, which fails identically on clean origin/main here).

* fix(config): notify reset removes every duplicate notify member

A hand-edited config may contain duplicate notify members (JSON decoders
tolerate them; the last occurrence wins). The reset path removed only
the final member, so an earlier block survived and became the effective
preference on the next decode — `zero config notify --reset` reported
success while the old value still applied (CodeRabbit review, PR #1001).

Loop the removal until no notify member remains, mirroring
setPetPreferenceJSON's duplicate handling. The replace path still edits
the LAST member, which is the one a decoder treats as effective, so an
earlier duplicate stays inert there.

Regression TestSetNotifyResetRemovesEveryDuplicateNotifyMember verifies
the reset-orphaned failure output on the pre-fix code and passes after;
it also pins the no-op reset and the last-member-replace behavior.

* fix(config): join UpdateNotify release error; restore main's test surface

CodeRabbit pass on the rebased head, three findings:

- UpdateNotify discarded the advisory-lock release error while every
  other writer joins it into the result (errors.Join). A failed unlock
  would leave later config updates blocked while the caller is told
  success. Now named-result + deferred Join, matching SetPet et al.
- The rebase had resolved model_test.go by taking the pre-rebase file,
  which silently dropped every test upstream added or changed since the
  fork point (composer blink/cursor suite, beginRun run-details,
  scrim-preserves-semantic-colors, session payload, Termux burst, ...).
  Rebuilt the file from origin/main plus only this branch's own additions
  (TestEffectiveTUINotifyMode), so main's newer assertions are back:
  the stale scrim assertion now expects semantic ANSI preservation, and
  the removed TestBeginRunResetsSidebarHidden stays removed exactly as
  upstream had already replaced it with TestBeginRunKeepsRunDetailsClosed.

* fix(tui): restore pickerTheme's transcript append; pin the picker failure path

Rebase regression (Vasanthdev2004, PR #1001): resolving the choosePicker
conflict attached the shared transcript-append line to the new
pickerNotify case, so the pickerTheme case lost it — a theme choice
whose preference could not be saved fell out with the session theme
changed and nothing reported, while the /theme text path kept its line,
so no test went red. Restore main's line in the pickerTheme case.

New regression TestThemePickerAppendNoteOnFailedSave drives the exact
reported shape: UserConfigPath under a regular file (the write must
fail), a pickerTheme item, choosePicker, and the could-not-save note
asserted in the transcript. Verified it fails on the pre-fix code with
the reviewer's own repro output and passes after.

Also restores the TestEffectiveTUINotifyMode comment to the post-review
wording (the default lives in the TUI, not config.Resolve, so headless
runs stay silent); the model_test.go rebuild had pulled the older
pre-notify phrasing back in.

* test(tui): restore the default palette after the picker regression test

Vasanthdev2004 follow-up (PR #1001): handleThemeCommand applies its
theme to the package-level zeroTheme, so TestThemePickerAppendNoteOnFailedSave
left dracula installed and the next palette-sensitive test
(TestFocusedPermissionSelectedRowUsesSelectionTintNotBrandChip) read
dracula's selection tint instead of the default. One line, the package's
existing convention: defer applyTheme(themeDark, true), as every other
theme-committing test already does. Verified the tint test fails in
sequence before the defer and the full internal/tui suite passes under
-race after.

* fix(tui): label the effective-default notify mode as "(default)"

jatmn round (PR #1001): the TUI printed the effective default mode as a
bare "both" while the new CLI labeled the same configuration "mode:
(default)" — the two first-party surfaces contradicted each other, and a
user could not tell whether "both" was stored or effective. Focus
already labeled its built-in default ("unfocused (default)"); mode now
mirrors it.

The model keeps the RAW configured mode (notifyConfiguredMode) alongside
the effective one, so state output can distinguish "both because the user
chose both" from "both because nothing is configured" — clearing to the
raw value when an explicit choice is made. effectiveModeLabel renders the
(default) tag on the /notify list card and the /notify change line, the
same wording the CLI read path uses. No defaults are persisted; resolver
and headless behavior untouched.

Regression TestNotifyStateLabelsEffectiveDefaultMode pins the contract:
unconfigured shows "both (default)", explicit stored "both" renders bare,
an explicit /notify bell choice renders bare in both the change line and
later state. Verified it fails on the pre-fix code and passes after.

* fix(config): one lock authority; full-replace across duplicate notify keys

jatmn round (PR #1001), two findings plus one documented precedence:

- UpdateNotify took a SECOND lock primitive over the same config.json.lock.
  On Windows its LockFileEx range did not overlap lockutil's, so notify
  writes could race provider/theme/credential/MCP writes and silently
  discard them; on every platform it also bypassed the established
  no-follow path checks and the bounded wait. Delete filelock_unix.go /
  filelock_windows.go entirely and use the existing lockConfigFileFn
  authority every other mutator already shares. New regression
  TestUpdateNotifyRacesExistingConfigMutatorWithoutLoss holds notify vs
  SetTheme and requires both acknowledged edits to survive; the real
  binary also races 30/30 clean cross-process on the shared lock.

- setNotifyJSONObject replaced only the last duplicate notify member, but
  Go's decoder MERGES the fields of duplicate members into one struct, so
  a replacement omitting a field inherited it from an earlier duplicate
  ({"notify":{"mode":"off"},"notify":{"focusMode":"focused"}} +
  SetNotify({FocusMode:"always"}) left mode "off" effective). The
  nonempty path now removes every duplicate and inserts the replacement
  fresh — full replace semantics, nothing inherited. UpdateNotify's
  partial merge is unaffected (it seeds omitted fields before the edit).
  Regression TestSetNotifyReplacementInheritsNoFieldFromEarlierDuplicate,
  verified failing on the pre-fix editor and passing after.

- zero config notify --reset combined with --mode/--focus now documents
  its precedence: --reset wins, explicit values are ignored (the help
  previously did not define the combination).

* fix(config): replace single notify member in place; reserve wipe for duplicates

Vasanthdev2004 review note (PR #1001, non-blocking): the duplicate-key fix
ran remove-and-reinsert for EVERY notify write, including the ordinary
single-member file — insertJSONMember appends after the last member, so
every /notify toggle moved the notify key to the end of the config object
and collapsed its formatting while everything around kept theirs.
Nothing was lost (member order carries no meaning), but a byte-preserving
write should not reformat what it need not touch.

setNotifyJSONObject now counts notify members first: exactly one is a
positional value replace (key stays where the user put it, surrounding
bytes untouched); two or more keeps the remove-every-duplicate-and-insert
path, where Go's field-merging decode would otherwise let an earlier
block leak fields into the replacement. Regression
TestSetNotifySingleMemberReplacedInPlace asserts exact bytes through an
in-place replace, verified failing on the over-eager version and passing
after; the duplicate regression and the whole config suite stay green.
2026-10-12 09:23:37 +08:00

566 lines
18 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 {
// The first non-flag argument is a subcommand. With no positional argument
// (or only flag arguments), the read-only summary path runs — this keeps
// `zero config` and `zero config --json` working unchanged.
command := "summary"
rest := args
if len(args) > 0 && !strings.HasPrefix(args[0], "-") {
command = strings.ToLower(strings.TrimSpace(args[0]))
rest = args[1:]
}
switch command {
case "summary":
return runConfigSummary(rest, stdout, stderr, deps)
case "notify":
return runConfigNotify(rest, stdout, stderr, deps)
case "help":
if err := writeConfigHelp(stdout); err != nil {
return exitCrash
}
return exitSuccess
}
return writeExecUsageError(stderr, fmt.Sprintf("unknown config command %q", command))
}
func runConfigSummary(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 {
setup := " setup: zero providers setup " + displayCLIValue(provider.ID, "unknown") + " --set-active"
if provider.ID == "atomic-chat-local" {
setup = " setup: run zero setup to select a loaded model, or zero providers detect to get an add command"
}
lines = append(lines, setup)
} 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]
zero config notify [flags]
Inspects resolved Go configuration without printing secrets. The notify
subcommand reads or updates the stored global notification preference,
which controls both the completion and needs-input alerts —
run "zero config notify --help" for details.
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, Atomic Chat) 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
}