mirror of
https://github.com/Twigpine/zero.git
synced 2026-11-10 13:11:31 -10:00
* feat(tui): offer mid-run model escalation behind --allow-escalation Mid-run escalation existed but only exec offered it. The TUI already handled every consequence of a switch, re-deriving the compaction threshold "after a mid-run escalate_model switch" and resolving the summarizer against the active profile, while nothing on that surface could cause one: escalate_model was registered by exec alone. This closes that, opt-in, the same conservative way exec answers the question. Opt-in rather than default because escalation moves a run onto a different model, which changes what it costs and which provider sees the conversation. That is the operator's call, and the exec flag already answers it. The switchers are built per turn from m.providerProfile rather than once at launch. A TUI session can change models with /model, so a closure capturing the startup profile would escalate from whatever the session began with instead of what is in force now, and would carry that stale profile's base URL and credential with it. exec has no such problem because its profile cannot change mid-run, which is why the wiring lives in different places on the two surfaces. The nil-contract logic is now shared rather than copied. providers. EscalationSwitchers is the single implementation both surfaces call: the loop swaps only on a non-nil provider, so a (nil, nil) return has to leave everything untouched including the caller's usage attribution, and the session switcher is installed only when the run STARTED optimized so escalation cannot change the transport underneath a session. A second copy of that would only ever have been exercised on one surface. exec's behaviour is unchanged: its six existing escalation tests pass against the shared builder. The tool and the switchers ride on one flag and a test asserts they cannot be separated. Registering escalate_model without wiring a switcher ships a tool the loop silently ignores, so the model reports an escalation that never happened; that is the specific failure worth a guard rather than a comment. Note this does not answer all of #554. Assigning models per phase, plan with one and execute with another, is still not built. This makes the mechanism reachable from the interactive surface, which is the part that was one flag away. Refs #554 * fix(tui): attribute usage after an escalation to the model in force exec reassigns currentModel when the escalation switcher fires; the TUI captured usageModelID once per run and handed the switcher no callback, so every usage event after a mid-run escalation was billed to the model the run started on. The switcher now updates usageModelID, and the final response carries the model per usage event so the batch fallback does not swing the other way and bill the events before the switch to the escalated model. Covered by a run through a scripted escalation that checks both the live and the batch attribution. * fix(cli): accept the root flags in any order Each leading-flag splitter stops at the first token it does not own, so running them once in a fixed sequence made the order load-bearing: `zero --allow-escalation --theme auto` stranded the theme as an unknown command and exited with an argument error instead of launching, and the same happened after --skip-permissions-unsafe. The three splitters now run until they make no progress, at the root and after the unsafe flag, so every ordering reaches the TUI with every flag applied. A --theme written before --skip-permissions-unsafe was dropped on that path; it is kept now, with a later one winning as the last occurrence does. * fix(cli): forward a root --allow-escalation into exec, reject it elsewhere The root flag was stripped before dispatch, so `zero --allow-escalation exec ...` and `zero --allow-escalation -p ...` ran without the opt-in the help text promises, and `zero --allow-escalation version` accepted a flag it could only discard. It now follows --add-dir: re-synthesised into the exec argument list for the exec and -p shapes, so exec parses it as its own flag, and rejected loudly for every other command. The ordering test now generates every permutation of the three root flags, with --skip-permissions-unsafe absent or at any position. * fix(tui): record the model on an escalated run persisted usage events The live usage record carried the model in force; the persisted session payload did not. The usage report rebuilds cost from that payload and falls back to the session-wide model when an event does not name one, so an escalated interactive run was priced end to end at the model it started on. Written only under escalation, matching what exec records under the same flag, so an ordinary run persists the same compact payload as before. * test(cli): isolate the new launch helpers from real user state A temporary working directory isolates project files and nothing else. runWithDeps fills the dependencies a test leaves out with production ones, and the interactive launch path reads user config, opens stores, refreshes the models.dev cache and migrates any inline plaintext API key into the credential store before it reaches an injected runTUI callback. Running these tests on a developer machine therefore rewrote that developer's config.json and wrote a credential file beside it, and the results depended on whatever providers, MCP servers and plugins the machine had. Both new helpers now point every per-user base directory at a throwaway root, pin the models.dev cache path, disable its background fetch, and force the file credential backend so nothing reaches the host keyring. A new test seeds a config with an inline key outside those fixture roots and fails if either helper rewrites it or leaves a file beside it; without the isolation it reports the rewritten config and the credentials.enc files. * test(cli): check each launch helper isolation on its own t.Setenv restores at the end of the test, not when the helper returns, so one test exercising both helpers let the first isolation cover for the second. Each helper now seeds its own emulated user config in a subtest, and removing the isolation from either one fails that subtest. * test(cli): pin the isolation itself, not only what this platform writes The exec helper writes its models.dev cache from a background goroutine, so a footprint check could not prove its isolation deterministically. Each subtest now also asserts the helper moved the per-user paths off the seeded root and disabled the fetch and the keyring backend, which fails for either helper when the isolation call is removed.
566 lines
19 KiB
Go
566 lines
19 KiB
Go
package cli
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"unicode"
|
|
|
|
"github.com/Gitlawb/zero/internal/config"
|
|
"github.com/Gitlawb/zero/internal/oauth"
|
|
"github.com/Gitlawb/zero/internal/providercatalog"
|
|
"github.com/Gitlawb/zero/internal/providerhealth"
|
|
"github.com/Gitlawb/zero/internal/provideronboarding"
|
|
"github.com/Gitlawb/zero/internal/tui"
|
|
)
|
|
|
|
type setupOptions struct {
|
|
catalogID string
|
|
name string
|
|
model string
|
|
baseURL string
|
|
apiKeyEnv string
|
|
json bool
|
|
// verify runs a live connectivity probe after saving the provider and reports
|
|
// a specific, fixable error on failure (wrong base URL, bad key, model not
|
|
// found) — the first-run "paste key -> working" confirmation.
|
|
verify bool
|
|
}
|
|
|
|
func runSetup(args []string, stdout io.Writer, stderr io.Writer, deps appDeps) int {
|
|
options, help, err := parseSetupArgs(args)
|
|
if err != nil {
|
|
return writeExecUsageError(stderr, err.Error())
|
|
}
|
|
if help {
|
|
if err := writeSetupHelp(stdout); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
if strings.TrimSpace(options.catalogID) == "" {
|
|
return runInteractiveTUIWithSetup(stderr, deps, "", nil, "", true, false)
|
|
}
|
|
|
|
result, err := saveSetupProvider(deps, tui.SetupSelection{
|
|
CatalogID: options.catalogID,
|
|
Model: options.model,
|
|
}, setupSaveOptions{
|
|
name: options.name,
|
|
baseURL: options.baseURL,
|
|
apiKeyEnv: options.apiKeyEnv,
|
|
})
|
|
if err != nil {
|
|
return writeAppError(stderr, err.Error(), exitCrash)
|
|
}
|
|
|
|
var verification *setupVerification
|
|
if options.verify {
|
|
verified, probeErr := verifySetupProvider(deps, result.Provider)
|
|
verification = &verified
|
|
if probeErr != nil {
|
|
// A failed probe is reported as a specific, fixable provider error — the
|
|
// profile is already saved, so the message tells the user the one thing to
|
|
// change and re-run, never a stack trace.
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, setupJSONPayload(result, verification)); err != nil {
|
|
return exitCrash
|
|
}
|
|
} else {
|
|
if _, err := fmt.Fprintln(stdout, formatSetupComplete(result)); err != nil {
|
|
return exitCrash
|
|
}
|
|
}
|
|
return writeAppError(stderr, "setup verification failed: "+probeErr.Error(), exitProvider)
|
|
}
|
|
}
|
|
|
|
if options.json {
|
|
if err := writePrettyJSON(stdout, setupJSONPayload(result, verification)); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
output := formatSetupComplete(result)
|
|
if verification != nil && verification.Ran && verification.OK {
|
|
output += "\nverified: " + verification.Summary
|
|
}
|
|
if _, err := fmt.Fprintln(stdout, output); err != nil {
|
|
return exitCrash
|
|
}
|
|
return exitSuccess
|
|
}
|
|
|
|
// setupVerification is the outcome of the optional first-run connectivity probe.
|
|
// Ran distinguishes "the probe ran and passed" (OK) from "no probe was wired so
|
|
// nothing was verified", so a skipped probe is never reported as verified.
|
|
type setupVerification struct {
|
|
Ran bool
|
|
OK bool
|
|
Summary string
|
|
}
|
|
|
|
// verifySetupProvider runs a live connectivity probe against the just-saved
|
|
// provider and classifies any failure into a specific, fixable message. When no
|
|
// probe is wired it reports a skipped (not-run) state rather than success, so it
|
|
// never blocks setup in a context that cannot probe and never claims a provider
|
|
// is verified when nothing was checked.
|
|
func verifySetupProvider(deps appDeps, profile config.ProviderProfile) (setupVerification, error) {
|
|
if deps.probeProviderHealth == nil {
|
|
return setupVerification{Ran: false, Summary: "probe unavailable; skipped"}, nil
|
|
}
|
|
// Distinguish "no key configured" from "key rejected": probing a remote provider
|
|
// with no credential yields a generic "the provider rejected the API key", which
|
|
// misleads a user who simply hasn't exported a key yet. Keyless local providers
|
|
// (loopback base_url) legitimately need no key, so they still probe. A profile may
|
|
// carry its key indirectly via APIKeyEnv (the setup result isn't env-resolved yet),
|
|
// so treat a populated env var as having a credential. (AUDIT-M1)
|
|
if !profileHasCredential(profile) && !baseURLIsLoopback(profile.BaseURL) {
|
|
name := strings.TrimSpace(profile.Name)
|
|
if name == "" {
|
|
name = "this provider"
|
|
}
|
|
return setupVerification{Ran: true, OK: false, Summary: "no api key"},
|
|
fmt.Errorf("no API key found for %s — set its API key (export the provider's API key env var, or pass --api-key-env) or run `zero auth`, then re-run setup", name)
|
|
}
|
|
ctx, stop := signalContext()
|
|
defer stop()
|
|
result := deps.probeProviderHealth(ctx, providerhealth.Options{
|
|
Profile: profile,
|
|
Connectivity: true,
|
|
UserAgent: userAgent(),
|
|
})
|
|
if probeErr, failed := provideronboarding.ClassifySetupProbe(result); failed {
|
|
return setupVerification{Ran: true, OK: false, Summary: string(probeErr.Class)}, probeErr
|
|
}
|
|
summary := "provider endpoint reachable"
|
|
if check := result.PrimaryCheck(); check != nil && strings.TrimSpace(check.Message) != "" {
|
|
summary = strings.TrimSpace(check.Message)
|
|
}
|
|
return setupVerification{Ran: true, OK: true, Summary: summary}, nil
|
|
}
|
|
|
|
func setupJSONPayload(result tui.SetupResult, verification *setupVerification) map[string]any {
|
|
payload := map[string]any{
|
|
"configPath": result.ConfigPath,
|
|
"provider": result.Provider.Name,
|
|
"model": result.Provider.Model,
|
|
"catalogID": result.Provider.CatalogID,
|
|
}
|
|
if verification != nil {
|
|
// Only emit the machine-readable verified flag when a probe actually ran, so
|
|
// a skipped probe is never reported as a passing verification.
|
|
if verification.Ran {
|
|
payload["verified"] = verification.OK
|
|
}
|
|
if verification.Summary != "" {
|
|
payload["verifyStatus"] = verification.Summary
|
|
}
|
|
}
|
|
return payload
|
|
}
|
|
|
|
func parseSetupArgs(args []string) (setupOptions, bool, error) {
|
|
options := setupOptions{}
|
|
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 arg == "--verify":
|
|
options.verify = true
|
|
case arg == "--name":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.name = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--name="):
|
|
value, err := requiredInlineFlagValue(arg, "--name")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.name = value
|
|
case arg == "--model":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.model = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--model="):
|
|
value, err := requiredInlineFlagValue(arg, "--model")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.model = value
|
|
case arg == "--base-url":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.baseURL = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--base-url="):
|
|
value, err := requiredInlineFlagValue(arg, "--base-url")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.baseURL = value
|
|
case arg == "--api-key-env":
|
|
value, next, err := nextFlagValue(args, index, arg)
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.apiKeyEnv = value
|
|
index = next
|
|
case strings.HasPrefix(arg, "--api-key-env="):
|
|
value, err := requiredInlineFlagValue(arg, "--api-key-env")
|
|
if err != nil {
|
|
return options, false, err
|
|
}
|
|
options.apiKeyEnv = value
|
|
case strings.HasPrefix(arg, "-"):
|
|
return options, false, execUsageError{fmt.Sprintf("unknown flag %q", arg)}
|
|
default:
|
|
if options.catalogID != "" {
|
|
return options, false, execUsageError{fmt.Sprintf("unexpected argument %q", arg)}
|
|
}
|
|
options.catalogID = arg
|
|
}
|
|
}
|
|
return options, false, nil
|
|
}
|
|
|
|
type setupSaveOptions struct {
|
|
name string
|
|
baseURL string
|
|
apiKeyEnv string
|
|
}
|
|
|
|
func saveSetupProvider(deps appDeps, selection tui.SetupSelection, options setupSaveOptions) (tui.SetupResult, error) {
|
|
profile, err := providerProfileForAdd(providerAddOptions{
|
|
catalogID: selection.CatalogID,
|
|
name: firstNonEmptyCLI(options.name, selection.Name),
|
|
model: firstNonEmptyCLI(selection.Model),
|
|
baseURL: firstNonEmptyCLI(options.baseURL, selection.BaseURL),
|
|
apiKeyEnv: options.apiKeyEnv,
|
|
setActive: true,
|
|
})
|
|
if err != nil {
|
|
return tui.SetupResult{}, err
|
|
}
|
|
if apiKey := strings.TrimSpace(selection.APIKey); apiKey != "" {
|
|
profile.APIKey = apiKey
|
|
profile.APIKeyEnv = ""
|
|
}
|
|
configPath, err := deps.userConfigPath()
|
|
if err != nil {
|
|
return tui.SetupResult{}, err
|
|
}
|
|
// Persist with the key moved into the encrypted credential store (capture flip);
|
|
// the returned profile keeps the key for this run's immediate use.
|
|
if _, err := config.UpsertProvider(configPath, config.SecureProviderProfile(profile, configPath), true); err != nil {
|
|
return tui.SetupResult{}, err
|
|
}
|
|
return tui.SetupResult{ConfigPath: configPath, Provider: profile}, nil
|
|
}
|
|
|
|
func setupProviderOptions() []tui.SetupProviderOption {
|
|
descriptors := providercatalog.All()
|
|
options := make([]tui.SetupProviderOption, 0, len(descriptors))
|
|
for _, descriptor := range descriptors {
|
|
if !providercatalog.RuntimeSupported(descriptor) {
|
|
continue
|
|
}
|
|
options = append(options, tui.SetupProviderOption{
|
|
ID: descriptor.ID,
|
|
Name: descriptor.Name,
|
|
DefaultModel: descriptor.DefaultModel,
|
|
EnvVar: setupProviderEnvVar(descriptor),
|
|
RequiresAuth: descriptor.RequiresAuth,
|
|
Local: descriptor.Local,
|
|
Recommended: descriptor.Recommended,
|
|
})
|
|
}
|
|
return options
|
|
}
|
|
|
|
func setupProviderEnvVar(descriptor providercatalog.Descriptor) string {
|
|
for _, envVar := range descriptor.AuthEnvVars {
|
|
if envVar = strings.TrimSpace(envVar); envVar != "" {
|
|
return envVar
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func setupRequired(resolved config.ResolvedConfig) bool {
|
|
if !config.HasProviderProfile(resolved.Provider) {
|
|
return true
|
|
}
|
|
if _, missing := setupMissingCredentialEnv(resolved.Provider); !missing {
|
|
return false
|
|
}
|
|
// A stored OAuth login (e.g. `zero auth login xai`) is a credential too, even
|
|
// though the profile has no inline key / env var — so a logged-in provider
|
|
// must not trigger onboarding.
|
|
return !providerHasOAuthLogin(resolved.Provider, oauthLoggedInProviders())
|
|
}
|
|
|
|
// providerHasOAuthLogin reports whether a stored OAuth login exists for the
|
|
// provider. It uses the same ProviderProfile.OAuthLoginCandidates the runtime
|
|
// resolver does, so the "authenticated in the UI" gate and the "authenticated at
|
|
// runtime" resolver can never diverge. This is only consulted for a profile with
|
|
// no usable API-key credential (setupRequired returns early otherwise), where
|
|
// the candidate set is permissive (profile name + catalog ID).
|
|
func providerHasOAuthLogin(profile config.ProviderProfile, oauthLogins map[string]bool) bool {
|
|
for _, name := range profile.OAuthLoginCandidates() {
|
|
// Store keys are normalized to lower case (oauth.ProviderKey), so the
|
|
// presence check must compare the same way.
|
|
if oauthLogins[strings.ToLower(strings.TrimSpace(name))] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// oauthLoggedInProviders returns the set of provider names that have a stored
|
|
// OAuth token, so credential checks recognize an OAuth login (not just inline
|
|
// keys / env vars). Errors degrade to an empty set (no logins).
|
|
func oauthLoggedInProviders() map[string]bool {
|
|
out := map[string]bool{}
|
|
store, err := oauth.NewStore(oauth.StoreOptions{})
|
|
if err != nil {
|
|
return out
|
|
}
|
|
statuses, err := store.Status(oauth.KeyPrefixProvider)
|
|
if err != nil {
|
|
return out
|
|
}
|
|
for _, status := range statuses {
|
|
if status.HasToken {
|
|
out[strings.ToLower(strings.TrimPrefix(status.Key, oauth.KeyPrefixProvider))] = true
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// firstUsableProvider returns the saved provider best suited to run without
|
|
// onboarding: the first usable (inline credential, stored OAuth login, or
|
|
// no-auth/local) non-local provider, else the first usable local one. It lets
|
|
// the CLI fall back to an already-configured login when the active provider
|
|
// happens to lack a credential, instead of re-running onboarding every launch.
|
|
func firstUsableProvider(providers []config.ProviderProfile) (config.ProviderProfile, bool) {
|
|
logins := oauthLoggedInProviders()
|
|
var localFallback config.ProviderProfile
|
|
haveLocal := false
|
|
for _, profile := range providers {
|
|
if !config.HasProviderProfile(profile) {
|
|
continue
|
|
}
|
|
// A profile whose catalog entry no longer resolves AND that has no explicit
|
|
// BaseURL has no endpoint to talk to, so it cannot become a working
|
|
// provider — skip it rather than picking a fallback that fails at first use.
|
|
// (A stale CatalogID with a BaseURL still works as a custom endpoint.)
|
|
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" && strings.TrimSpace(profile.BaseURL) == "" {
|
|
if _, err := providercatalog.Require(catalogID); err != nil {
|
|
continue
|
|
}
|
|
}
|
|
// A stored OAuth login (e.g. `zero auth login xai`) is a credential too, even
|
|
// when the profile has no inline key / env var — mirrors setupRequired and
|
|
// usableSavedProviders so this fallback doesn't force onboarding for a
|
|
// provider the user is already authenticated with.
|
|
if _, missing := setupMissingCredentialEnv(profile); missing && !providerHasOAuthLogin(profile, logins) {
|
|
continue
|
|
}
|
|
if providerProfileIsLocal(profile) {
|
|
if !haveLocal {
|
|
localFallback = profile
|
|
haveLocal = true
|
|
}
|
|
continue
|
|
}
|
|
return profile, true
|
|
}
|
|
if haveLocal {
|
|
return localFallback, true
|
|
}
|
|
return config.ProviderProfile{}, false
|
|
}
|
|
|
|
// usableSavedProviders filters configured providers to those the user can actually
|
|
// use: an inline/stored/env key, an auth header, a stored OAuth login, or a no-auth
|
|
// local provider. This keeps /model from listing providers that are merely present
|
|
// in config.json but never authenticated (e.g. a default openai entry with no key).
|
|
func usableSavedProviders(providers []config.ProviderProfile) []config.ProviderProfile {
|
|
logins := oauthLoggedInProviders()
|
|
store, storeErr := config.ProviderKeyStore()
|
|
usable := make([]config.ProviderProfile, 0, len(providers))
|
|
for _, profile := range providers {
|
|
if !config.HasProviderProfile(profile) {
|
|
continue
|
|
}
|
|
// A provider whose only credential is the APIKeyStored marker counts as usable
|
|
// only when the key is actually retrievable — the keyring/file entry may have
|
|
// been deleted, leaving a stale marker that would otherwise list it in /model.
|
|
if profile.APIKeyStored && strings.TrimSpace(profile.APIKey) == "" {
|
|
if storeErr == nil {
|
|
if key, ok, err := store.Get(profile.Name); err == nil && ok && strings.TrimSpace(key) != "" {
|
|
usable = append(usable, profile)
|
|
continue
|
|
}
|
|
}
|
|
// Marker present but key missing/unreadable: fall through and judge the
|
|
// profile on its other signals (env var, OAuth, local) with the marker off.
|
|
withoutMarker := profile
|
|
withoutMarker.APIKeyStored = false
|
|
if _, missing := setupMissingCredentialEnv(withoutMarker); !missing {
|
|
usable = append(usable, profile)
|
|
} else if providerHasOAuthLogin(profile, logins) {
|
|
usable = append(usable, profile)
|
|
}
|
|
continue
|
|
}
|
|
if _, missing := setupMissingCredentialEnv(profile); !missing {
|
|
usable = append(usable, profile)
|
|
continue
|
|
}
|
|
if providerHasOAuthLogin(profile, logins) {
|
|
usable = append(usable, profile)
|
|
}
|
|
}
|
|
return usable
|
|
}
|
|
|
|
// providerProfileIsLocal reports whether a provider points at a local endpoint
|
|
// (a loopback URL or a catalog entry flagged Local), so the fallback prefers a
|
|
// remote keyed provider that is more likely reachable.
|
|
func providerProfileIsLocal(profile config.ProviderProfile) bool {
|
|
if catalogID := strings.TrimSpace(profile.CatalogID); catalogID != "" {
|
|
if descriptor, err := providercatalog.Require(catalogID); err == nil && descriptor.Local {
|
|
return true
|
|
}
|
|
}
|
|
base := strings.TrimSpace(profile.BaseURL)
|
|
if base == "" {
|
|
return false
|
|
}
|
|
parsed, err := url.Parse(base)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
switch strings.ToLower(parsed.Hostname()) {
|
|
case "localhost", "127.0.0.1", "::1":
|
|
return true
|
|
}
|
|
// Also treat explicit private-network IPs (192.168.x.y, 10.x.y.z,
|
|
// 172.16-31.x.y) as local — the user is pointing at their own LAN box.
|
|
if ip := net.ParseIP(parsed.Hostname()); ip != nil && ip.IsPrivate() {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
func formatSetupComplete(result tui.SetupResult) string {
|
|
lines := []string{"Zero setup complete"}
|
|
if result.Provider.Name != "" {
|
|
lines = append(lines, "provider: "+result.Provider.Name)
|
|
}
|
|
if result.Provider.Model != "" {
|
|
lines = append(lines, "model: "+result.Provider.Model)
|
|
}
|
|
if result.ConfigPath != "" {
|
|
lines = append(lines, "config: "+result.ConfigPath)
|
|
}
|
|
if envVar, ok := setupMissingCredentialEnv(result.Provider); ok {
|
|
if envVar != "" {
|
|
lines = append(lines, "next: set "+envVar+" in your shell")
|
|
} else {
|
|
lines = append(lines, "next: set provider credentials in your shell")
|
|
}
|
|
}
|
|
lines = append(lines, "next: "+setupCheckCommand(result.Provider.Name), "next: zero")
|
|
lines = append(lines, "try this: "+setupTryThisExample(result.Provider))
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
// setupTryThisExample returns a concrete one-line headless run the user can paste
|
|
// to confirm a real completion, parameterized by the model just configured. This
|
|
// is the "end the wizard in a working state" example from the first-run spec.
|
|
func setupTryThisExample(profile config.ProviderProfile) string {
|
|
example := `zero exec "say hello in one short sentence"`
|
|
if model := strings.TrimSpace(profile.Model); model != "" {
|
|
example += " --model " + setupCommandArg(model)
|
|
}
|
|
return example
|
|
}
|
|
|
|
// setupMissingCredentialEnv reports the environment variable a saved profile
|
|
// is expected to read an API key from, and whether that expectation is
|
|
// unmet. It delegates to config.ProviderProfile.MissingCredentialEnv, the
|
|
// single source of truth shared with the TUI's provider status/wizard
|
|
// surfaces and the CLI's `providers check` readiness gate, so all three never
|
|
// disagree about whether a saved provider is missing a credential.
|
|
func setupMissingCredentialEnv(profile config.ProviderProfile) (string, bool) {
|
|
return profile.MissingCredentialEnv()
|
|
}
|
|
|
|
func setupCheckCommand(name string) string {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return "zero providers check --connectivity"
|
|
}
|
|
return "zero providers check " + setupCommandArg(name) + " --connectivity"
|
|
}
|
|
|
|
func setupCommandArg(value string) string {
|
|
if value == "" {
|
|
return strconv.Quote(value)
|
|
}
|
|
for _, r := range value {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
continue
|
|
}
|
|
switch r {
|
|
case '-', '_', '.', '/', ':', '@':
|
|
continue
|
|
default:
|
|
return strconv.Quote(value)
|
|
}
|
|
}
|
|
return value
|
|
}
|
|
|
|
func writeSetupHelp(w io.Writer) error {
|
|
_, err := fmt.Fprint(w, `Usage:
|
|
zero setup [provider] [flags]
|
|
|
|
Guides first-run Zero setup. Without a provider, opens the setup UI. With a
|
|
provider catalog id, writes that provider as the active provider.
|
|
|
|
Examples:
|
|
zero setup
|
|
zero setup openai --api-key-env OPENAI_API_KEY
|
|
zero setup ollama
|
|
zero setup ollama-cloud
|
|
|
|
Flags:
|
|
--name <name> Provider profile name
|
|
--model <model> Override the default model
|
|
--base-url <url> Override provider base URL
|
|
--api-key-env <name> Store an API key environment variable name
|
|
--verify Probe the provider after saving and report a fixable error on failure
|
|
--json Print machine-readable setup result
|
|
-h, --help Show this help
|
|
`)
|
|
return err
|
|
}
|