feat: add CLI status command, rewrite setup wizard, and modernize table renderer

Three interconnected CLI improvements:

New status command (cmd/status.go):
- Fetches live claude.ai subscription data using the claudeai client
- Renders rate-limit windows (5-hour, 7-day all/Opus/Sonnet) with
  color-coded progress bars: green <50%, orange 50-80%, red >80%
- Shows overage spend limits and usage percentage
- Handles partial data gracefully (renders what's available)
- Clear onboarding guidance when no session key is configured

Setup wizard rewrite (cmd/setup.go):
- Replace raw bufio.Reader prompts with charmbracelet/huh multi-step form
- Three form groups: welcome screen, credentials (session key + admin key
  with password echo mode), and preferences (time range + theme select)
- Pre-populates from existing config, preserves existing keys on empty input
- Dracula theme for the form UI
- Graceful Ctrl+C handling via huh.ErrUserAborted

Table renderer modernization (internal/cli/render.go):
- Replace 120-line manual box-drawing table renderer with lipgloss/table
- Automatic column width calculation, rounded borders, right-aligned
  numeric columns (all except first)
- Filter out "---" separator sentinels (not supported by lipgloss/table)
- Remove unused style variables (valueStyle, costStyle, tokenStyle,
  warnStyle) and Table.Widths field

Config display update (cmd/config_cmd.go):
- Show claude.ai session key and org ID in config output

Dependencies (go.mod):
- Add charmbracelet/huh v0.8.0 for form-based TUI wizards
- Upgrade golang.org/x/text v0.3.8 -> v0.23.0
- Add transitive deps: catppuccin/go, harmonica, hashstructure, etc.
This commit is contained in:
teernisse
2026-02-20 16:07:40 -05:00
parent 547d402578
commit e241ee3966
6 changed files with 389 additions and 217 deletions

View File

@@ -1,3 +1,4 @@
// Package cmd implements the cburn CLI commands.
package cmd
import (
@@ -24,7 +25,7 @@ func runConfig(_ *cobra.Command, _ []string) error {
return err
}
fmt.Printf(" Config file: %s\n", config.ConfigPath())
fmt.Printf(" Config file: %s\n", config.Path())
if config.Exists() {
fmt.Println(" Status: loaded")
} else {
@@ -40,6 +41,18 @@ func runConfig(_ *cobra.Command, _ []string) error {
}
fmt.Println()
fmt.Println(" [Claude.ai]")
sessionKey := config.GetSessionKey(cfg)
if sessionKey != "" {
fmt.Printf(" Session key: %s\n", maskAPIKey(sessionKey))
} else {
fmt.Println(" Session key: not configured")
}
if cfg.ClaudeAI.OrgID != "" {
fmt.Printf(" Org ID: %s\n", cfg.ClaudeAI.OrgID)
}
fmt.Println()
fmt.Println(" [Admin API]")
apiKey := config.GetAdminAPIKey(cfg)
if apiKey != "" {

View File

@@ -1,14 +1,15 @@
package cmd
import (
"bufio"
"errors"
"fmt"
"os"
"strings"
"cburn/internal/config"
"cburn/internal/source"
"cburn/internal/tui/theme"
"github.com/charmbracelet/huh"
"github.com/spf13/cobra"
)
@@ -23,89 +24,119 @@ func init() {
}
func runSetup(_ *cobra.Command, _ []string) error {
reader := bufio.NewReader(os.Stdin)
// Load existing config or defaults
cfg, _ := config.Load()
// Count sessions
files, _ := source.ScanDir(flagDataDir)
projectCount := source.CountProjects(files)
fmt.Println()
fmt.Println(" Welcome to cburn!")
fmt.Println()
// Pre-populate from existing config
var sessionKey, adminKey string
days := cfg.General.DefaultDays
if days == 0 {
days = 30
}
themeName := cfg.Appearance.Theme
if themeName == "" {
themeName = "flexoki-dark"
}
// Build welcome description
welcomeDesc := "Let's configure your dashboard."
if len(files) > 0 {
fmt.Printf(" Found %s sessions in %s (%d projects)\n\n",
formatNumber(int64(len(files))), flagDataDir, projectCount)
welcomeDesc = fmt.Sprintf("Found %d sessions across %d projects in %s.",
len(files), projectCount, flagDataDir)
}
// 1. API key
fmt.Println(" 1. Anthropic Admin API key")
fmt.Println(" For real cost data from the billing API.")
existing := config.GetAdminAPIKey(cfg)
if existing != "" {
fmt.Printf(" Current: %s\n", maskAPIKey(existing))
// Build placeholder text showing masked existing values
sessionPlaceholder := "sk-ant-sid... (Enter to skip)"
if key := config.GetSessionKey(cfg); key != "" {
sessionPlaceholder = maskAPIKey(key) + " (Enter to keep)"
}
fmt.Print(" > ")
apiKey, _ := reader.ReadString('\n')
apiKey = strings.TrimSpace(apiKey)
if apiKey != "" {
cfg.AdminAPI.APIKey = apiKey
}
fmt.Println()
// 2. Default time range
fmt.Println(" 2. Default time range")
fmt.Println(" (1) 7 days")
fmt.Println(" (2) 30 days [default]")
fmt.Println(" (3) 90 days")
fmt.Print(" > ")
choice, _ := reader.ReadString('\n')
choice = strings.TrimSpace(choice)
switch choice {
case "1":
cfg.General.DefaultDays = 7
case "3":
cfg.General.DefaultDays = 90
default:
cfg.General.DefaultDays = 30
}
fmt.Println()
// 3. Theme
fmt.Println(" 3. Color theme")
fmt.Println(" (1) Flexoki Dark [default]")
fmt.Println(" (2) Catppuccin Mocha")
fmt.Println(" (3) Tokyo Night")
fmt.Println(" (4) Terminal (ANSI 16)")
fmt.Print(" > ")
themeChoice, _ := reader.ReadString('\n')
themeChoice = strings.TrimSpace(themeChoice)
switch themeChoice {
case "2":
cfg.Appearance.Theme = "catppuccin-mocha"
case "3":
cfg.Appearance.Theme = "tokyo-night"
case "4":
cfg.Appearance.Theme = "terminal"
default:
cfg.Appearance.Theme = "flexoki-dark"
adminPlaceholder := "sk-ant-admin-... (Enter to skip)"
if key := config.GetAdminAPIKey(cfg); key != "" {
adminPlaceholder = maskAPIKey(key) + " (Enter to keep)"
}
// Save
form := huh.NewForm(
huh.NewGroup(
huh.NewNote().
Title("Welcome to cburn").
Description(welcomeDesc).
Next(true).
NextLabel("Start"),
),
huh.NewGroup(
huh.NewInput().
Title("Claude.ai session key").
Description("For rate-limit and subscription data.\nclaude.ai > DevTools > Application > Cookies > sessionKey").
Placeholder(sessionPlaceholder).
EchoMode(huh.EchoModePassword).
Value(&sessionKey),
huh.NewInput().
Title("Anthropic Admin API key").
Description("For real cost data from the billing API.").
Placeholder(adminPlaceholder).
EchoMode(huh.EchoModePassword).
Value(&adminKey),
),
huh.NewGroup(
huh.NewSelect[int]().
Title("Default time range").
Options(
huh.NewOption("7 days", 7),
huh.NewOption("30 days", 30),
huh.NewOption("90 days", 90),
).
Value(&days),
huh.NewSelect[string]().
Title("Color theme").
Options(themeOpts()...).
Value(&themeName),
),
).WithTheme(huh.ThemeDracula())
if err := form.Run(); err != nil {
if errors.Is(err, huh.ErrUserAborted) {
fmt.Println("\n Setup cancelled.")
return nil
}
return fmt.Errorf("setup form: %w", err)
}
// Only overwrite keys if the user typed new ones
sessionKey = strings.TrimSpace(sessionKey)
if sessionKey != "" {
cfg.ClaudeAI.SessionKey = sessionKey
}
adminKey = strings.TrimSpace(adminKey)
if adminKey != "" {
cfg.AdminAPI.APIKey = adminKey
}
cfg.General.DefaultDays = days
cfg.Appearance.Theme = themeName
if err := config.Save(cfg); err != nil {
return fmt.Errorf("saving config: %w", err)
}
fmt.Println()
fmt.Printf(" Saved to %s\n", config.ConfigPath())
fmt.Printf("\n Saved to %s\n", config.Path())
fmt.Println(" Run `cburn setup` anytime to reconfigure.")
fmt.Println()
return nil
}
func themeOpts() []huh.Option[string] {
opts := make([]huh.Option[string], len(theme.All))
for i, t := range theme.All {
opts[i] = huh.NewOption(t.Name, t.Name)
}
return opts
}
func maskAPIKey(key string) string {
if len(key) > 16 {
return key[:8] + "..." + key[len(key)-4:]

198
cmd/status.go Normal file
View File

@@ -0,0 +1,198 @@
package cmd
import (
"context"
"errors"
"fmt"
"os"
"strings"
"time"
"cburn/internal/claudeai"
"cburn/internal/cli"
"cburn/internal/config"
"github.com/charmbracelet/lipgloss"
"github.com/spf13/cobra"
)
var statusCmd = &cobra.Command{
Use: "status",
Short: "Show claude.ai subscription status and rate limits",
RunE: runStatus,
}
func init() {
rootCmd.AddCommand(statusCmd)
}
func runStatus(_ *cobra.Command, _ []string) error {
cfg, _ := config.Load()
sessionKey := config.GetSessionKey(cfg)
if sessionKey == "" {
fmt.Println()
fmt.Println(" No session key configured.")
fmt.Println()
fmt.Println(" To get your session key:")
fmt.Println(" 1. Open claude.ai in your browser")
fmt.Println(" 2. DevTools (F12) > Application > Cookies > claude.ai")
fmt.Println(" 3. Copy the 'sessionKey' value (starts with sk-ant-sid...)")
fmt.Println()
fmt.Println(" Then configure it:")
fmt.Println(" cburn setup (interactive)")
fmt.Println(" CLAUDE_SESSION_KEY=sk-ant-sid... cburn status (one-shot)")
fmt.Println()
return nil
}
client := claudeai.NewClient(sessionKey)
if client == nil {
return errors.New("invalid session key format (expected sk-ant-sid... prefix)")
}
if !flagQuiet {
fmt.Fprintf(os.Stderr, " Fetching subscription data...\n")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
data := client.FetchAll(ctx)
if data.Error != nil {
if errors.Is(data.Error, claudeai.ErrUnauthorized) {
return errors.New("session key expired or invalid — grab a fresh one from claude.ai cookies")
}
if errors.Is(data.Error, claudeai.ErrRateLimited) {
return errors.New("rate limited by claude.ai — try again in a minute")
}
// Partial data may still be available, continue rendering
if data.Usage == nil && data.Overage == nil {
return fmt.Errorf("fetch failed: %w", data.Error)
}
}
fmt.Println()
fmt.Println(cli.RenderTitle("CLAUDE.AI STATUS"))
fmt.Println()
// Organization info
if data.Org.UUID != "" {
fmt.Printf(" Organization: %s\n", data.Org.Name)
if len(data.Org.Capabilities) > 0 {
fmt.Printf(" Capabilities: %s\n", strings.Join(data.Org.Capabilities, ", "))
}
fmt.Println()
}
// Rate limits
if data.Usage != nil {
rows := [][]string{}
if w := data.Usage.FiveHour; w != nil {
rows = append(rows, rateLimitRow("5-hour window", w))
}
if w := data.Usage.SevenDay; w != nil {
rows = append(rows, rateLimitRow("7-day (all)", w))
}
if w := data.Usage.SevenDayOpus; w != nil {
rows = append(rows, rateLimitRow("7-day Opus", w))
}
if w := data.Usage.SevenDaySonnet; w != nil {
rows = append(rows, rateLimitRow("7-day Sonnet", w))
}
if len(rows) > 0 {
fmt.Print(cli.RenderTable(cli.Table{
Title: "Rate Limits",
Headers: []string{"Window", "Used", "Bar", "Resets"},
Rows: rows,
}))
}
}
// Overage
if data.Overage != nil {
ol := data.Overage
status := "disabled"
if ol.IsEnabled {
status = "enabled"
}
rows := [][]string{
{"Overage", status},
{"Used Credits", fmt.Sprintf("%.2f %s", ol.UsedCredits, ol.Currency)},
{"Monthly Limit", fmt.Sprintf("%.2f %s", ol.MonthlyCreditLimit, ol.Currency)},
}
if ol.IsEnabled && ol.MonthlyCreditLimit > 0 {
pct := ol.UsedCredits / ol.MonthlyCreditLimit
rows = append(rows, []string{"Usage", fmt.Sprintf("%.1f%%", pct*100)})
}
fmt.Print(cli.RenderTable(cli.Table{
Title: "Overage Spend",
Headers: []string{"Setting", "Value"},
Rows: rows,
}))
}
// Partial error warning
if data.Error != nil {
warnStyle := lipgloss.NewStyle().Foreground(cli.ColorOrange)
fmt.Printf(" %s\n\n", warnStyle.Render(fmt.Sprintf("Partial data — %s", data.Error)))
}
fmt.Printf(" Fetched at %s\n\n", data.FetchedAt.Format("3:04:05 PM"))
return nil
}
func rateLimitRow(label string, w *claudeai.ParsedWindow) []string {
pctStr := fmt.Sprintf("%.0f%%", w.Pct*100)
bar := renderMiniBar(w.Pct, 20)
resets := ""
if !w.ResetsAt.IsZero() {
dur := time.Until(w.ResetsAt)
if dur > 0 {
resets = formatCountdown(dur)
} else {
resets = "now"
}
}
return []string{label, pctStr, bar, resets}
}
func renderMiniBar(pct float64, width int) string {
if pct < 0 {
pct = 0
}
if pct > 1 {
pct = 1
}
filled := int(pct * float64(width))
empty := width - filled
// Color based on usage level
color := cli.ColorGreen
if pct >= 0.8 {
color = cli.ColorRed
} else if pct >= 0.5 {
color = cli.ColorOrange
}
barStyle := lipgloss.NewStyle().Foreground(color)
dimStyle := lipgloss.NewStyle().Foreground(cli.ColorTextDim)
return barStyle.Render(strings.Repeat("█", filled)) +
dimStyle.Render(strings.Repeat("░", empty))
}
func formatCountdown(d time.Duration) string {
h := int(d.Hours())
m := int(d.Minutes()) % 60
if h > 0 {
return fmt.Sprintf("%dh %dm", h, m)
}
return fmt.Sprintf("%dm", m)
}