mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-11 05:01:36 -10:00
* 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.
566 lines
18 KiB
Go
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
|
|
}
|