// Package cmd implements interactive subcommands invoked from the CLI. // setup.go contains the provider onboarding wizard reachable via // `cliamp setup`. It walks the user through configuring each remote // provider (Navidrome, Plex, Jellyfin, Spotify, Qobuz, NetEase, YouTube Music), // validates the connection where possible, and writes the resulting // TOML section to ~/.config/cliamp/config.toml. // // The UI is a small standalone Bubbletea+Lipgloss program — separate from // the main player Model — because setup runs to completion and exits. package cmd import ( "context" "errors" "fmt" "io/fs" "net/url" "os" "path/filepath" "strconv" "strings" "time" "unicode/utf8" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" "github.com/bjarneo/cliamp/external/emby" "github.com/bjarneo/cliamp/external/jellyfin" "github.com/bjarneo/cliamp/external/navidrome" "github.com/bjarneo/cliamp/external/netease" "github.com/bjarneo/cliamp/external/plex" "github.com/bjarneo/cliamp/internal/appdir" ) // Setup launches the interactive wizard. Returns nil on clean exit. func Setup() error { prog := tea.NewProgram(newSetupModel()) _, err := prog.Run() return err } // ----- Provider specs ----------------------------------------------------- type fieldSpec struct { key string label string help string // shown faintly under the field required bool secret bool defaultV string // onlyIf, when non-nil, hides the field unless the predicate returns true // against the current values map. Used by Jellyfin for token-vs-password. onlyIf func(map[string]string) bool } type providerSpec struct { key string name string section string intro []string // pre-form blurb (with help URLs) picker *pickerSpec fields []fieldSpec // validate runs a probe against the live server. Nil skips validation. validate func(map[string]string) error // body returns the TOML block body (no header). body func(map[string]string) string // extraValidate runs after the form before validate, e.g. to enforce // "token OR (user+password)" for Jellyfin. extraValidate func(map[string]string) error } // pickerSpec is an optional radio choice shown before the fields. The // selected option's key is stored in values[key]. fieldSpec.onlyIf can // reference this to show different fields per choice. type pickerSpec struct { key string label string options []pickerOption } type pickerOption struct { value string label string } // Picker keys are stored in the values map alongside real field keys; the // leading underscore distinguishes them from TOML field names. const ( keyJellyfinAuth = "_auth" keyEmbyAuth = "_emby_auth" keyNetEaseBrowser = "_netease_browser" keyYTMusicMode = "_mode" keySpotifyMode = "_spotify_mode" keyQobuzQuality = "_qobuz_quality" ) func providers() []providerSpec { return []providerSpec{ { key: "navidrome", name: "Navidrome / Subsonic", section: "navidrome", intro: []string{ "Self-hosted music server using the Subsonic API.", "Docs: cliamp.stream → docs/navidrome.md", }, fields: []fieldSpec{ {key: "url", label: "Server URL", help: "e.g. https://music.example.com", required: true}, {key: "user", label: "Username", required: true}, {key: "password", label: "Password", required: true, secret: true}, }, validate: func(v map[string]string) error { return navidrome.New(v["url"], v["user"], v["password"]).Ping() }, body: func(v map[string]string) string { return strings.Join([]string{ fmt.Sprintf("url = %q", v["url"]), fmt.Sprintf("user = %q", v["user"]), fmt.Sprintf("password = %q", v["password"]), }, "\n") }, }, { key: "plex", name: "Plex Media Server", section: "plex", intro: []string{ "Stream from your Plex server using an X-Plex-Token.", "Find a token: https://support.plex.tv/articles/204059436", }, fields: []fieldSpec{ {key: "url", label: "Server URL", help: "e.g. http://192.168.1.10:32400", required: true}, {key: "token", label: "X-Plex-Token", required: true, secret: true}, }, validate: func(v map[string]string) error { return plex.NewClient(v["url"], v["token"]).Ping() }, body: func(v map[string]string) string { return strings.Join([]string{ fmt.Sprintf("url = %q", v["url"]), fmt.Sprintf("token = %q", v["token"]), }, "\n") }, }, { key: "jellyfin", name: "Jellyfin", section: "jellyfin", intro: []string{ "Authenticate with an API token (Dashboard → API Keys)", "or with your username and password.", }, picker: &pickerSpec{ key: keyJellyfinAuth, label: "Authentication", options: []pickerOption{ {value: "token", label: "API token"}, {value: "password", label: "Username + password"}, }, }, fields: []fieldSpec{ {key: "url", label: "Server URL", help: "e.g. https://jellyfin.example.com", required: true}, {key: "token", label: "API token", required: true, secret: true, onlyIf: func(v map[string]string) bool { return v[keyJellyfinAuth] == "token" }}, {key: "user", label: "Username", required: true, onlyIf: func(v map[string]string) bool { return v[keyJellyfinAuth] == "password" }}, {key: "password", label: "Password", required: true, secret: true, onlyIf: func(v map[string]string) bool { return v[keyJellyfinAuth] == "password" }}, }, validate: func(v map[string]string) error { return jellyfin.NewClient(v["url"], v["token"], "", v["user"], v["password"]).Ping() }, body: func(v map[string]string) string { lines := []string{fmt.Sprintf("url = %q", v["url"])} if v[keyJellyfinAuth] == "token" { lines = append(lines, fmt.Sprintf("token = %q", v["token"])) } else { lines = append(lines, fmt.Sprintf("user = %q", v["user"]), fmt.Sprintf("password = %q", v["password"]), ) } return strings.Join(lines, "\n") }, }, { key: "emby", name: "Emby", section: "emby", intro: []string{ "Authenticate with an API key (Dashboard → API Keys)", "or with your username and password.", }, picker: &pickerSpec{ key: keyEmbyAuth, label: "Authentication", options: []pickerOption{ {value: "token", label: "API key"}, {value: "password", label: "Username + password"}, }, }, fields: []fieldSpec{ {key: "url", label: "Server URL", help: "e.g. https://emby.example.com", required: true}, {key: "token", label: "API key", required: true, secret: true, onlyIf: func(v map[string]string) bool { return v[keyEmbyAuth] == "token" }}, {key: "user", label: "Username (optional)", help: "multi-user servers: picks your account from /Users", onlyIf: func(v map[string]string) bool { return v[keyEmbyAuth] == "token" }}, {key: "user", label: "Username", required: true, onlyIf: func(v map[string]string) bool { return v[keyEmbyAuth] == "password" }}, {key: "password", label: "Password", required: true, secret: true, onlyIf: func(v map[string]string) bool { return v[keyEmbyAuth] == "password" }}, }, validate: func(v map[string]string) error { if err := emby.NewClient(v["url"], v["token"], "", v["user"], v["password"]).Ping(); err != nil { return fmt.Errorf("emby: validation: %w", err) } return nil }, body: func(v map[string]string) string { lines := []string{fmt.Sprintf("url = %q", v["url"])} if v[keyEmbyAuth] == "token" { lines = append(lines, fmt.Sprintf("token = %q", v["token"])) if v["user"] != "" { lines = append(lines, fmt.Sprintf("user = %q", v["user"])) } } else { lines = append(lines, fmt.Sprintf("user = %q", v["user"]), fmt.Sprintf("password = %q", v["password"]), ) } return strings.Join(lines, "\n") }, }, { key: "spotify", name: "Spotify (Premium)", section: "spotify", intro: []string{ "Requires a Spotify Premium account.", "", "Recommended: register your own Spotify Developer app at", "developer.spotify.com/dashboard (redirect URI", "http://127.0.0.1:19872/login). Your own client_id gives you a", "private rate-limit quota and works for playback, library, and", "playlists. Apps registered after Nov 27, 2024 can't use", "/v1/search though — that's a Spotify dev-mode restriction.", "", "Alternative: cliamp ships a built-in client_id (the librespot", "keymaster) that bypasses the search restriction. It's shared", "with every librespot- and spotify-player-based client, so you", "may see occasional 429 errors when the pool is busy.", }, picker: &pickerSpec{ key: keySpotifyMode, label: "Client ID", options: []pickerOption{ {value: "custom", label: "Use my own Spotify Developer app client_id"}, {value: "default", label: "Use built-in shared client_id"}, }, }, fields: []fieldSpec{ {key: "client_id", label: "Client ID", required: true, help: "from developer.spotify.com/dashboard; redirect URI http://127.0.0.1:19872/login", onlyIf: func(v map[string]string) bool { return v[keySpotifyMode] == "custom" }}, {key: "bitrate", label: "Bitrate", help: "96, 160, or 320 kbps", defaultV: "320"}, }, extraValidate: func(v map[string]string) error { if v["bitrate"] == "" { return nil } if _, err := strconv.Atoi(v["bitrate"]); err != nil { return fmt.Errorf("bitrate must be a number") } return nil }, body: func(v map[string]string) string { br := v["bitrate"] if br == "" { br = "320" } lines := []string{} if v[keySpotifyMode] == "custom" && v["client_id"] != "" { lines = append(lines, fmt.Sprintf("client_id = %q", v["client_id"])) } lines = append(lines, fmt.Sprintf("bitrate = %s", br)) return strings.Join(lines, "\n") }, }, { key: "qobuz", name: "Qobuz", section: "qobuz", intro: []string{ "Lossless streaming. Requires an active Qobuz subscription.", "", "No API credentials needed - cliamp obtains them automatically.", "After setup, launch cliamp, select Qobuz, and press Enter to", "sign in via OAuth in your browser. Hi-Res tiers require a plan", "that includes them.", }, picker: &pickerSpec{ key: keyQobuzQuality, label: "Stream quality", options: []pickerOption{ {value: "6", label: "FLAC 16-bit/44.1kHz (CD) - recommended"}, {value: "7", label: "FLAC 24-bit up to 96kHz (Hi-Res)"}, {value: "27", label: "FLAC 24-bit up to 192kHz (Hi-Res)"}, {value: "5", label: "MP3 320kbps"}, }, }, body: func(v map[string]string) string { q := v[keyQobuzQuality] if q == "" { q = "6" } return strings.Join([]string{ "enabled = true", fmt.Sprintf("quality = %s", q), }, "\n") }, }, { key: "netease", name: "NetEase Cloud Music", section: "netease", intro: []string{ "Reuses your browser session through yt-dlp cookies.", "Sign in at music.163.com first, then pick that browser here.", }, picker: &pickerSpec{ key: keyNetEaseBrowser, label: "Browser session", options: []pickerOption{ {value: "chrome", label: "Chrome"}, {value: "safari", label: "Safari"}, {value: "firefox", label: "Firefox"}, {value: "brave", label: "Brave"}, {value: "edge", label: "Edge"}, {value: "chromium", label: "Chromium"}, {value: "vivaldi", label: "Vivaldi"}, {value: "custom", label: "Custom browser/profile"}, }, }, fields: []fieldSpec{ {key: "cookies_from", label: "Custom browser/profile", help: "e.g. chrome:Profile 1, firefox:default-release", required: true, onlyIf: func(v map[string]string) bool { return v[keyNetEaseBrowser] == "custom" }}, }, validate: func(v map[string]string) error { browser := netEaseCookiesFrom(v) if browser == "" { return fmt.Errorf("browser is required") } v["cookies_from"] = browser ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) defer cancel() acc, err := netease.CheckLogin(ctx, browser) if err != nil { return fmt.Errorf("netease: validation: %w", err) } v["user_id"] = acc.UserID return nil }, body: func(v map[string]string) string { browser := netEaseCookiesFrom(v) lines := []string{ "enabled = true", fmt.Sprintf("cookies_from = %q", browser), } if v["user_id"] != "" { lines = append(lines, fmt.Sprintf("user_id = %q", v["user_id"])) } return strings.Join(lines, "\n") }, }, { key: "ytmusic", name: "YouTube Music", section: "ytmusic", intro: []string{ "Works out of the box with built-in fallback credentials.", "Provide your own OAuth client to skip the shared pool, and/or", "a browser name for cookie-based age-gated playback.", }, picker: &pickerSpec{ key: keyYTMusicMode, label: "Mode", options: []pickerOption{ {value: "default", label: "Use built-in credentials (recommended)"}, {value: "custom", label: "Provide my own OAuth credentials / cookies"}, {value: "off", label: "Disable YouTube Music"}, }, }, fields: []fieldSpec{ {key: "client_id", label: "OAuth Client ID", onlyIf: func(v map[string]string) bool { return v[keyYTMusicMode] == "custom" }}, {key: "client_secret", label: "OAuth Client Secret", secret: true, onlyIf: func(v map[string]string) bool { return v[keyYTMusicMode] == "custom" }}, {key: "cookies_from", label: "Cookies from browser", help: "e.g. chrome, firefox; blank to skip", onlyIf: func(v map[string]string) bool { return v[keyYTMusicMode] == "custom" }}, }, body: func(v map[string]string) string { switch v[keyYTMusicMode] { case "off": return "enabled = false" case "custom": lines := []string{"enabled = true"} if v["client_id"] != "" { lines = append(lines, fmt.Sprintf("client_id = %q", v["client_id"])) } if v["client_secret"] != "" { lines = append(lines, fmt.Sprintf("client_secret = %q", v["client_secret"])) } if v["cookies_from"] != "" { lines = append(lines, fmt.Sprintf("cookies_from = %q", v["cookies_from"])) } return strings.Join(lines, "\n") default: return "enabled = true" } }, }, } } func netEaseCookiesFrom(v map[string]string) string { picked := strings.TrimSpace(v[keyNetEaseBrowser]) if picked == "" || picked == "custom" { return strings.TrimSpace(v["cookies_from"]) } return picked } // ----- Bubbletea model ---------------------------------------------------- type stage int const ( stageMenu stage = iota stagePicker stageForm stageValidating stageResult ) type setupModel struct { provs []providerSpec w, h int stage stage menuCursor int // active provider (index into provs); -1 means none pidx int // picker state (within a provider) pickerCursor int // form state values map[string]string visible []int // indices into provs[pidx].fields that are currently visible fcursor int // validating spinFrame int // result state resultErr error resultWarning bool // validation failed but config still valid resultText string // overall message (e.g. "Saved [navidrome] section.") saveFailed error // io error writing config (rare; shown as error result) awaitingSave bool // true after a failed validate: waiting for y/n // cached so we don't hit os.UserHomeDir per-frame cfgPath string } func newSetupModel() *setupModel { cfgPath, _ := configFilePath() return &setupModel{ provs: providers(), stage: stageMenu, pidx: -1, values: map[string]string{}, cfgPath: cfgPath, } } func (m *setupModel) Init() tea.Cmd { return func() tea.Msg { return tea.RequestWindowSize() } } // ----- Messages ----------------------------------------------------------- type validateDoneMsg struct{ err error } type spinTickMsg struct{} func spinTickCmd() tea.Cmd { return tea.Tick(120*time.Millisecond, func(time.Time) tea.Msg { return spinTickMsg{} }) } func runValidateCmd(spec providerSpec, values map[string]string) tea.Cmd { return func() tea.Msg { if spec.validate == nil { return validateDoneMsg{} } return validateDoneMsg{err: spec.validate(values)} } } // ----- Update ------------------------------------------------------------- func (m *setupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.w, m.h = msg.Width, msg.Height return m, nil case tea.KeyPressMsg: return m.handleKey(msg) case tea.PasteMsg: return m.handlePaste(msg.Content) case spinTickMsg: if m.stage == stageValidating { m.spinFrame++ return m, spinTickCmd() } return m, nil case validateDoneMsg: return m.onValidateDone(msg.err) } return m, nil } // handlePaste appends bracketed-paste content into the active form field. // Newlines and other control characters are stripped — the wizard's fields // are all single-line, and pasted credentials often have a trailing newline // from the source app. func (m *setupModel) handlePaste(content string) (tea.Model, tea.Cmd) { if content == "" || m.stage != stageForm || len(m.visible) == 0 { return m, nil } clean := sanitizePaste(content) if clean == "" { return m, nil } field := m.provs[m.pidx].fields[m.visible[m.fcursor]] m.values[field.key] += clean return m, nil } func sanitizePaste(s string) string { var b strings.Builder b.Grow(len(s)) for _, r := range s { // Drop newlines and other ASCII control chars; keep spaces and tabs (tabs as space). switch { case r == '\n' || r == '\r': continue case r == '\t': b.WriteRune(' ') case r < 0x20: continue default: b.WriteRune(r) } } return b.String() } func (m *setupModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if msg.String() == "ctrl+c" { return m, tea.Quit } switch m.stage { case stageMenu: return m.menuKey(msg) case stagePicker: return m.pickerKey(msg) case stageForm: return m.formKey(msg) case stageValidating: // Ignore input while probing. return m, nil case stageResult: return m.resultKey(msg) } return m, nil } func (m *setupModel) menuKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "q", "esc": return m, tea.Quit case "up", "k": if m.menuCursor > 0 { m.menuCursor-- } case "down", "j": if m.menuCursor < len(m.provs)-1 { m.menuCursor++ } case "enter": m.startProvider(m.menuCursor) } return m, nil } func (m *setupModel) startProvider(idx int) { m.pidx = idx m.values = map[string]string{} m.fcursor = 0 m.pickerCursor = 0 m.resultErr = nil m.resultText = "" m.resultWarning = false m.awaitingSave = false m.saveFailed = nil if m.provs[idx].picker != nil { m.stage = stagePicker } else { m.refreshVisibleFields() m.stage = stageForm } } func (m *setupModel) pickerKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { p := m.provs[m.pidx].picker switch msg.String() { case "esc": m.stage = stageMenu return m, nil case "up", "k": if m.pickerCursor > 0 { m.pickerCursor-- } case "down", "j": if m.pickerCursor < len(p.options)-1 { m.pickerCursor++ } case "enter": m.values[p.key] = p.options[m.pickerCursor].value // Pickers can short-circuit with no fields (e.g. ytmusic "default" / "off"). m.refreshVisibleFields() if len(m.visible) == 0 { return m.submitForm() } m.stage = stageForm } return m, nil } func (m *setupModel) formKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if len(m.visible) == 0 { // Defensive: shouldn't happen; submit anyway. return m.submitForm() } field := m.provs[m.pidx].fields[m.visible[m.fcursor]] cur := m.values[field.key] switch msg.Code { case tea.KeyEscape: m.stage = stageMenu return m, nil case tea.KeyUp: if m.fcursor > 0 { m.fcursor-- } return m, nil case tea.KeyTab, tea.KeyDown: // Shift+Tab arrives with ModShift; treat it as "previous field". if msg.Mod&tea.ModShift != 0 { if m.fcursor > 0 { m.fcursor-- } return m, nil } if m.fcursor < len(m.visible)-1 { m.fcursor++ } return m, nil case tea.KeyEnter: // Enter on the last field submits; otherwise advance. if m.fcursor < len(m.visible)-1 { m.fcursor++ return m, nil } return m.submitForm() case tea.KeyBackspace: if cur != "" { m.values[field.key] = removeLastRune(cur) } return m, nil case tea.KeySpace: m.values[field.key] = cur + " " return m, nil } // Treat ctrl+s / ctrl+d as submit shortcuts. if s := msg.String(); s == "ctrl+s" || s == "ctrl+d" { return m.submitForm() } if len(msg.Text) > 0 { m.values[field.key] = cur + msg.Text } return m, nil } func (m *setupModel) submitForm() (tea.Model, tea.Cmd) { spec := m.provs[m.pidx] // Apply defaults for blank fields. for _, f := range spec.fields { if m.values[f.key] == "" && f.defaultV != "" { m.values[f.key] = f.defaultV } } // Required-field check (only for visible fields). for i, idx := range m.visible { f := spec.fields[idx] if f.required && strings.TrimSpace(m.values[f.key]) == "" { m.fcursor = i m.resultErr = fmt.Errorf("%s is required", f.label) m.resultText = "" m.stage = stageResult return m, nil } } if spec.extraValidate != nil { if err := spec.extraValidate(m.values); err != nil { m.resultErr = err m.stage = stageResult return m, nil } } // Light URL sanity check before any network call. if u, ok := m.values["url"]; ok && u != "" { clean := strings.TrimRight(u, "/") if !looksLikeHTTPURL(clean) { m.resultErr = fmt.Errorf("URL must start with http:// or https://") m.stage = stageResult return m, nil } m.values["url"] = clean } if spec.validate == nil { // No probe; save immediately. return m, m.persistAndDone(false) } m.stage = stageValidating m.spinFrame = 0 return m, tea.Batch(spinTickCmd(), runValidateCmd(spec, m.values)) } func (m *setupModel) onValidateDone(err error) (tea.Model, tea.Cmd) { if err == nil { return m, m.persistAndDone(false) } m.resultErr = err m.awaitingSave = true m.stage = stageResult return m, nil } // persistAndDone writes the section and transitions to result. warn=true // indicates the user opted to save despite a failed probe. func (m *setupModel) persistAndDone(warn bool) tea.Cmd { spec := m.provs[m.pidx] body := spec.body(m.values) if err := saveSection(spec.section, body); err != nil { m.saveFailed = err m.stage = stageResult m.awaitingSave = false return nil } m.stage = stageResult m.awaitingSave = false m.resultWarning = warn m.resultText = fmt.Sprintf("Saved [%s] section.", spec.section) return nil } func (m *setupModel) resultKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if m.awaitingSave { switch strings.ToLower(msg.String()) { case "y": m.persistAndDone(true) return m, nil case "n", "esc": m.awaitingSave = false m.resultErr = nil m.stage = stageMenu return m, nil } return m, nil } switch msg.String() { case "enter", "esc", "q", " ": m.stage = stageMenu return m, nil } return m, nil } // refreshVisibleFields recomputes the visible-field index list based on // the current values map, then resets the cursor to a sensible position. func (m *setupModel) refreshVisibleFields() { spec := m.provs[m.pidx] m.visible = m.visible[:0] for i, f := range spec.fields { if f.onlyIf == nil || f.onlyIf(m.values) { m.visible = append(m.visible, i) } } if m.fcursor >= len(m.visible) { m.fcursor = 0 } } // ----- Rendering ---------------------------------------------------------- var ( // Color choices match the existing player palette; ANSI numbers give // good contrast against any terminal theme. titleStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(10)).Bold(true) hintStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(7)) dimStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(8)) accentStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(11)).Bold(true) errStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(9)).Bold(true) okStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(10)).Bold(true) warnStyle = lipgloss.NewStyle().Foreground(lipgloss.ANSIColor(11)).Bold(true) cardStyle = lipgloss.NewStyle(). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.ANSIColor(8)). Padding(1, 2) activeFieldStyle = lipgloss.NewStyle(). Border(lipgloss.NormalBorder(), false, false, false, true). BorderForeground(lipgloss.ANSIColor(11)). PaddingLeft(1) inactiveFieldStyle = lipgloss.NewStyle().PaddingLeft(2) ) const ( maxCardWidth = 78 logoLine1 = " cliamp setup" logoLine2 = " configure remote providers" ) func (m *setupModel) View() tea.View { header := titleStyle.Render(logoLine1) + "\n" + hintStyle.Render(logoLine2) + "\n" var body string switch m.stage { case stageMenu: body = m.viewMenu() case stagePicker: body = m.viewPicker() case stageForm: body = m.viewForm() case stageValidating: body = m.viewValidating() case stageResult: body = m.viewResult() } footer := "\n" + m.viewFooter() view := tea.NewView(header + "\n" + body + footer) view.AltScreen = true return view } // card wraps body in the standard rounded-border card sized to the terminal. func (m *setupModel) card(body string) string { return cardStyle.Width(min(maxCardWidth, m.viewWidth())).Render(body) } func (m *setupModel) viewMenu() string { var b strings.Builder b.WriteString(accentStyle.Render("Pick a provider to configure")) b.WriteString("\n\n") for i, p := range m.provs { marker := " " nameRender := p.name if i == m.menuCursor { marker = accentStyle.Render("▸ ") nameRender = accentStyle.Render(p.name) } b.WriteString(marker) b.WriteString(nameRender) b.WriteString("\n") // Render the first intro line as a one-liner per item, faintly. if len(p.intro) > 0 { indent := " " b.WriteString(indent) b.WriteString(hintStyle.Render(p.intro[0])) b.WriteString("\n") } } b.WriteString("\n") b.WriteString(dimStyle.Render("config: " + m.cfgPath)) return m.card(b.String()) } func (m *setupModel) viewPicker() string { spec := m.provs[m.pidx] p := spec.picker var b strings.Builder b.WriteString(accentStyle.Render(spec.name)) b.WriteString("\n\n") for _, line := range spec.intro { b.WriteString(hintStyle.Render(line)) b.WriteString("\n") } b.WriteString("\n") b.WriteString(accentStyle.Render(p.label)) b.WriteString("\n\n") for i, opt := range p.options { marker := " " text := opt.label if i == m.pickerCursor { marker = accentStyle.Render("▸ ") text = accentStyle.Render(opt.label) } b.WriteString(marker) b.WriteString(text) b.WriteString("\n") } return m.card(b.String()) } func (m *setupModel) viewForm() string { spec := m.provs[m.pidx] var b strings.Builder b.WriteString(accentStyle.Render(spec.name)) b.WriteString("\n\n") for _, line := range spec.intro { b.WriteString(hintStyle.Render(line)) b.WriteString("\n") } b.WriteString("\n") for i, idx := range m.visible { f := spec.fields[idx] val := m.values[f.key] display := val if f.secret { display = strings.Repeat("•", utf8.RuneCountInString(val)) } labelLine := f.label if f.required { labelLine += accentStyle.Render(" *") } valueLine := display if i == m.fcursor { // Active: show cursor caret at end of value. valueLine = display + accentStyle.Render("▎") block := labelLine + "\n" + valueLine if f.help != "" { block += "\n" + dimStyle.Render(f.help) } b.WriteString(activeFieldStyle.Render(block)) } else { if valueLine == "" { valueLine = dimStyle.Render("(empty)") } block := labelLine + "\n" + valueLine b.WriteString(inactiveFieldStyle.Render(block)) } b.WriteString("\n\n") } return m.card(strings.TrimRight(b.String(), "\n")) } func (m *setupModel) viewValidating() string { frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"} frame := frames[m.spinFrame%len(frames)] body := accentStyle.Render(frame) + " " + "Verifying connection to " + m.provs[m.pidx].name + "…" return cardStyle.Width(min(maxCardWidth, m.viewWidth())).Render(body) } func (m *setupModel) viewResult() string { spec := m.provs[m.pidx] var b strings.Builder b.WriteString(accentStyle.Render(spec.name)) b.WriteString("\n\n") if m.saveFailed != nil { b.WriteString(errStyle.Render("✗ Failed to write config")) b.WriteString("\n\n") b.WriteString(m.saveFailed.Error()) b.WriteString("\n\n") b.WriteString(hintStyle.Render("Press any key to return to the menu.")) return m.card(b.String()) } if m.awaitingSave { b.WriteString(errStyle.Render("✗ Validation failed")) b.WriteString("\n\n") b.WriteString(m.resultErr.Error()) b.WriteString("\n\n") b.WriteString(hintStyle.Render("The config will still load on next launch — useful when the server is offline now.")) b.WriteString("\n\n") b.WriteString(accentStyle.Render("Save anyway? ") + "[y/N]") return m.card(b.String()) } if m.resultErr != nil { b.WriteString(errStyle.Render("✗ ")) b.WriteString(m.resultErr.Error()) b.WriteString("\n\n") b.WriteString(hintStyle.Render("Press any key to return to the menu.")) return m.card(b.String()) } if m.resultWarning { b.WriteString(warnStyle.Render("⚠ Saved without verification")) } else { b.WriteString(okStyle.Render("✓ " + m.resultText)) } b.WriteString("\n\n") b.WriteString(dimStyle.Render(m.cfgPath)) b.WriteString("\n\n") b.WriteString(hintStyle.Render("Press any key to configure another provider, or q to quit.")) return m.card(b.String()) } func (m *setupModel) viewFooter() string { var keys string switch m.stage { case stageMenu: keys = "↑/↓ pick enter select q quit" case stagePicker: keys = "↑/↓ pick enter confirm esc back" case stageForm: keys = "↑/↓ field enter submit esc back" case stageValidating: keys = "ctrl+c cancel" case stageResult: if m.awaitingSave { keys = "y save anyway n cancel" } else { keys = "any key continue q quit" } } return dimStyle.Render(keys) } func (m *setupModel) viewWidth() int { if m.w <= 4 { return maxCardWidth } return m.w - 2 } // ----- Helpers ------------------------------------------------------------ func looksLikeHTTPURL(s string) bool { u, err := url.Parse(s) if err != nil { return false } return u.Scheme == "http" || u.Scheme == "https" } func configFilePath() (string, error) { dir, err := appdir.Dir() if err != nil { return "", err } return filepath.Join(dir, "config.toml"), nil } // removeLastRune trims the final UTF-8 rune from s. func removeLastRune(s string) string { if len(s) > 0 { _, size := utf8.DecodeLastRuneInString(s) return s[:len(s)-size] } return s } // saveSection rewrites or appends a [section] block in config.toml. The // body is the raw TOML between the header and the next section/EOF; it // must not contain a header line itself. Existing content for the same // section is replaced; everything else is preserved as-is. func saveSection(section, body string) error { path, err := configFilePath() if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } header := "[" + section + "]" block := header + "\n" + strings.TrimRight(body, "\n") + "\n" data, err := os.ReadFile(path) if err != nil { if !errors.Is(err, fs.ErrNotExist) { return err } return os.WriteFile(path, []byte(block), 0o644) } lines := strings.Split(string(data), "\n") start, end := findSection(lines, section) if start < 0 { out := strings.TrimRight(string(data), "\n") if out != "" { out += "\n\n" } out += block return os.WriteFile(path, []byte(out), 0o644) } before := strings.TrimRight(strings.Join(lines[:start], "\n"), "\n") after := "" if end < len(lines) { after = strings.TrimLeft(strings.Join(lines[end:], "\n"), "\n") } var b strings.Builder if before != "" { b.WriteString(before) b.WriteString("\n\n") } b.WriteString(block) if after != "" { b.WriteString("\n") b.WriteString(after) if !strings.HasSuffix(after, "\n") { b.WriteString("\n") } } return os.WriteFile(path, []byte(b.String()), 0o644) } // findSection returns [start, end) line indices for the [name] block in // lines, where start points at the header line and end points at the // next section header (or len(lines) if it's the last block). Returns // (-1, -1) if the section is absent. func findSection(lines []string, name string) (int, int) { target := "[" + strings.ToLower(name) + "]" start := -1 for i, l := range lines { t := strings.ToLower(strings.TrimSpace(l)) if t == target { start = i break } } if start < 0 { return -1, -1 } for i := start + 1; i < len(lines); i++ { t := strings.TrimSpace(lines[i]) if strings.HasPrefix(t, "[") && strings.HasSuffix(t, "]") { return start, i } } return start, len(lines) }