// cli/main.go — Bubble Tea TUI starter for the scaffolded app. // // Build: go build -o {APP_SLUG}-cli ./cli // Run: APP_BASE=http://localhost:3000 APP_SLUG=app ./{APP_SLUG}-cli // // First run prompts for an API token. Get one at {APP_BASE}/settings/tokens. // Token stored at ~/.{APP_SLUG}rc.json with mode 0600. // // This is a minimum viable list view. Customize the resource type, columns, // and key bindings for your app's domain. package main import ( "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "time" "github.com/charmbracelet/bubbles/list" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/charmbracelet/log" ) var ( base = envOr("APP_BASE", "http://localhost:3000") slug = envOr("APP_SLUG", "app") titleStyle = lipgloss.NewStyle().Bold(true).Padding(0, 1) hintStyle = lipgloss.NewStyle().Faint(true) ) type config struct { Token string `json:"token"` } func configPath() string { home, _ := os.UserHomeDir() return filepath.Join(home, "."+slug+"rc.json") } func loadConfig() config { b, err := os.ReadFile(configPath()) if err != nil { return config{} } var c config _ = json.Unmarshal(b, &c) return c } func saveConfig(c config) error { b, _ := json.MarshalIndent(c, "", " ") return os.WriteFile(configPath(), b, 0o600) } func envOr(k, def string) string { if v := os.Getenv(k); v != "" { return v } return def } type item struct { id, title string done bool } func (i item) FilterValue() string { return i.title } func (i item) Title() string { if i.done { return "✓ " + i.title } return "· " + i.title } func (i item) Description() string { return i.id } type model struct { list list.Model input textinput.Model mode string // "login" | "list" err error width int height int } func initialModel() model { cfg := loadConfig() l := list.New(nil, list.NewDefaultDelegate(), 0, 0) l.Title = fmt.Sprintf("%s — items", slug) l.SetShowHelp(true) ti := textinput.New() ti.Placeholder = "paste token from " + base + "/settings/tokens" ti.Focus() ti.CharLimit = 256 m := model{list: l, input: ti} if cfg.Token == "" { m.mode = "login" } else { m.mode = "list" } return m } func (m model) Init() tea.Cmd { if m.mode == "list" { return fetchItems } return nil } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: m.width, m.height = msg.Width, msg.Height m.list.SetSize(msg.Width, msg.Height-4) case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": return m, tea.Quit case "enter": if m.mode == "login" { token := m.input.Value() if err := saveConfig(config{Token: token}); err != nil { m.err = err return m, nil } m.mode = "list" return m, fetchItems } } case itemsMsg: its := make([]list.Item, len(msg)) for i, it := range msg { its[i] = it } m.list.SetItems(its) case errMsg: m.err = msg } var cmd tea.Cmd if m.mode == "login" { m.input, cmd = m.input.Update(msg) } else { m.list, cmd = m.list.Update(msg) } return m, cmd } func (m model) View() string { if m.err != nil { return titleStyle.Render("error") + "\n" + m.err.Error() + "\n\n" + hintStyle.Render("press q to quit") } if m.mode == "login" { return titleStyle.Render("Sign in") + "\n\n" + m.input.View() + "\n\n" + hintStyle.Render("enter to save · q to quit") } return m.list.View() } type itemsMsg []item type errMsg error func fetchItems() tea.Msg { cfg := loadConfig() req, _ := http.NewRequest("GET", base+"/api/items", nil) req.Header.Set("Authorization", "Bearer "+cfg.Token) client := &http.Client{Timeout: 10 * time.Second} res, err := client.Do(req) if err != nil { return errMsg(err) } defer res.Body.Close() if res.StatusCode != 200 { body, _ := io.ReadAll(res.Body) return errMsg(fmt.Errorf("%d: %s", res.StatusCode, string(body))) } var data struct { Items []item `json:"items"` } if err := json.NewDecoder(res.Body).Decode(&data); err != nil { return errMsg(err) } return itemsMsg(data.Items) } func main() { if _, err := tea.NewProgram(initialModel(), tea.WithAltScreen()).Run(); err != nil { log.Fatal(err) } }