diff --git a/cmd/config_cmd.go b/cmd/config_cmd.go new file mode 100644 index 0000000..5066e92 --- /dev/null +++ b/cmd/config_cmd.go @@ -0,0 +1,69 @@ +package cmd + +import ( + "fmt" + + "cburn/internal/config" + + "github.com/spf13/cobra" +) + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Show current configuration", + RunE: runConfig, +} + +func init() { + rootCmd.AddCommand(configCmd) +} + +func runConfig(_ *cobra.Command, _ []string) error { + cfg, err := config.Load() + if err != nil { + return err + } + + fmt.Printf(" Config file: %s\n", config.ConfigPath()) + if config.Exists() { + fmt.Println(" Status: loaded") + } else { + fmt.Println(" Status: using defaults (no config file)") + } + fmt.Println() + + fmt.Println(" [General]") + fmt.Printf(" Default days: %d\n", cfg.General.DefaultDays) + fmt.Printf(" Include subagents: %v\n", cfg.General.IncludeSubagents) + if cfg.General.ClaudeDir != "" { + fmt.Printf(" Claude directory: %s\n", cfg.General.ClaudeDir) + } + fmt.Println() + + fmt.Println(" [Admin API]") + apiKey := config.GetAdminAPIKey(cfg) + if apiKey != "" { + fmt.Printf(" API key: %s\n", maskAPIKey(apiKey)) + } else { + fmt.Println(" API key: not configured") + } + fmt.Println() + + fmt.Println(" [Appearance]") + fmt.Printf(" Theme: %s\n", cfg.Appearance.Theme) + fmt.Println() + + fmt.Println(" [Budget]") + if cfg.Budget.MonthlyUSD != nil { + fmt.Printf(" Monthly budget: $%.0f\n", *cfg.Budget.MonthlyUSD) + } else { + fmt.Println(" Monthly budget: not set") + } + + planInfo := config.DetectPlan(flagDataDir) + fmt.Printf(" Plan ceiling: $%.0f (auto-detected)\n", planInfo.PlanCeiling) + fmt.Println() + + fmt.Println(" Run `cburn setup` to reconfigure.") + return nil +} diff --git a/cmd/costs.go b/cmd/costs.go new file mode 100644 index 0000000..d09a9d8 --- /dev/null +++ b/cmd/costs.go @@ -0,0 +1,164 @@ +package cmd + +import ( + "fmt" + + "cburn/internal/cli" + "cburn/internal/config" + "cburn/internal/pipeline" + + "github.com/spf13/cobra" +) + +var costsCmd = &cobra.Command{ + Use: "costs", + Short: "Cost breakdown by token type and model", + RunE: runCosts, +} + +func init() { + rootCmd.AddCommand(costsCmd) +} + +func runCosts(_ *cobra.Command, _ []string) error { + result, err := loadData() + if err != nil { + return err + } + if len(result.Sessions) == 0 { + fmt.Println("\n No sessions found.") + return nil + } + + filtered, since, until := applyFilters(result.Sessions) + stats := pipeline.Aggregate(filtered, since, until) + models := pipeline.AggregateModels(filtered, since, until) + + if stats.TotalSessions == 0 { + fmt.Println("\n No sessions in the selected time range.") + return nil + } + + // Previous period for comparison + prevDuration := until.Sub(since) + prevSince := since.Add(-prevDuration) + prevStats := pipeline.Aggregate(filtered, prevSince, since) + + fmt.Println() + fmt.Println(cli.RenderTitle(fmt.Sprintf("COST BREAKDOWN Last %dd", flagDays))) + fmt.Println() + + // Cost by token type + type tokenCost struct { + name string + cost float64 + } + + // Calculate costs per token type from raw token counts using canonical pricing + var inputCost, outputCost, cache5mCost, cache1hCost, cacheReadCost float64 + for _, s := range pipeline.FilterByTime(filtered, since, until) { + for modelName, mu := range s.Models { + p, ok := config.LookupPricing(modelName) + if !ok { + continue + } + inputCost += float64(mu.InputTokens) * p.InputPerMTok / 1_000_000 + outputCost += float64(mu.OutputTokens) * p.OutputPerMTok / 1_000_000 + cache5mCost += float64(mu.CacheCreation5mTokens) * p.CacheWrite5mPerMTok / 1_000_000 + cache1hCost += float64(mu.CacheCreation1hTokens) * p.CacheWrite1hPerMTok / 1_000_000 + cacheReadCost += float64(mu.CacheReadTokens) * p.CacheReadPerMTok / 1_000_000 + } + } + + totalCost := inputCost + outputCost + cache5mCost + cache1hCost + cacheReadCost + + costs := []tokenCost{ + {"Output", outputCost}, + {"Cache Write (1h)", cache1hCost}, + {"Input", inputCost}, + {"Cache Write (5m)", cache5mCost}, + {"Cache Read", cacheReadCost}, + } + + // Sort by cost descending (already in expected order, but ensure) + typeRows := make([][]string, 0, len(costs)+2) + for _, tc := range costs { + pct := "" + if totalCost > 0 { + pct = fmt.Sprintf("%.1f%%", tc.cost/totalCost*100) + } + typeRows = append(typeRows, []string{tc.name, cli.FormatCost(tc.cost), pct}) + } + typeRows = append(typeRows, []string{"---"}) + typeRows = append(typeRows, []string{"TOTAL", cli.FormatCost(totalCost), ""}) + + fmt.Print(cli.RenderTable(cli.Table{ + Title: "By Token Type", + Headers: []string{"Type", "Cost", "Share"}, + Rows: typeRows, + })) + + // Period comparison + if prevStats.EstimatedCost > 0 { + fmt.Printf(" Period Comparison\n") + maxCost := stats.EstimatedCost + if prevStats.EstimatedCost > maxCost { + maxCost = prevStats.EstimatedCost + } + fmt.Printf(" This %dd %s %s\n", + flagDays, + cli.RenderHorizontalBar("", stats.EstimatedCost, maxCost, 30), + cli.FormatCost(stats.EstimatedCost)) + fmt.Printf(" Prev %dd %s %s\n\n", + flagDays, + cli.RenderHorizontalBar("", prevStats.EstimatedCost, maxCost, 30), + cli.FormatCost(prevStats.EstimatedCost)) + } + + // Cost by model + modelRows := make([][]string, 0, len(models)+2) + for _, ms := range models { + p, _ := config.LookupPricing(ms.Model) + mInput := float64(ms.InputTokens) * p.InputPerMTok / 1_000_000 + mOutput := float64(ms.OutputTokens) * p.OutputPerMTok / 1_000_000 + mCache := float64(ms.CacheCreation5m)*p.CacheWrite5mPerMTok/1_000_000 + + float64(ms.CacheCreation1h)*p.CacheWrite1hPerMTok/1_000_000 + + float64(ms.CacheReadTokens)*p.CacheReadPerMTok/1_000_000 + + modelRows = append(modelRows, []string{ + shortModel(ms.Model), + cli.FormatCost(mInput), + cli.FormatCost(mOutput), + cli.FormatCost(mCache), + cli.FormatCost(ms.EstimatedCost), + }) + } + modelRows = append(modelRows, []string{"---"}) + modelRows = append(modelRows, []string{ + "TOTAL", + cli.FormatCost(inputCost), + cli.FormatCost(outputCost), + cli.FormatCost(cache5mCost + cache1hCost + cacheReadCost), + cli.FormatCost(totalCost), + }) + + fmt.Print(cli.RenderTable(cli.Table{ + Title: "By Model", + Headers: []string{"Model", "Input", "Output", "Cache", "Total"}, + Rows: modelRows, + })) + + fmt.Printf(" Cache Savings: %s saved this period\n\n", + cli.FormatCost(stats.CacheSavings)) + + return nil +} + +func shortModel(name string) string { + // "claude-opus-4-6" -> "opus-4-6" + if len(name) > 7 && name[:7] == "claude-" { + return name[7:] + } + return name +} + diff --git a/cmd/daily.go b/cmd/daily.go new file mode 100644 index 0000000..a858199 --- /dev/null +++ b/cmd/daily.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "fmt" + + "cburn/internal/cli" + "cburn/internal/pipeline" + + "github.com/spf13/cobra" +) + +var dailyCmd = &cobra.Command{ + Use: "daily", + Short: "Daily usage table", + RunE: runDaily, +} + +func init() { + rootCmd.AddCommand(dailyCmd) +} + +func runDaily(_ *cobra.Command, _ []string) error { + result, err := loadData() + if err != nil { + return err + } + if len(result.Sessions) == 0 { + fmt.Println("\n No sessions found.") + return nil + } + + filtered, since, until := applyFilters(result.Sessions) + days := pipeline.AggregateDays(filtered, since, until) + + if len(days) == 0 { + fmt.Println("\n No data for the selected period.") + return nil + } + + fmt.Println() + fmt.Println(cli.RenderTitle(fmt.Sprintf("DAILY USAGE Last %dd", flagDays))) + fmt.Println() + + rows := make([][]string, 0, len(days)) + for _, d := range days { + rows = append(rows, []string{ + d.Date.Format("2006-01-02"), + cli.FormatDayOfWeek(int(d.Date.Weekday())), + cli.FormatNumber(int64(d.Sessions)), + cli.FormatNumber(int64(d.Prompts)), + cli.FormatTokens(d.InputTokens + d.OutputTokens + d.CacheCreation5m + d.CacheCreation1h), + cli.FormatCost(d.EstimatedCost), + }) + } + + fmt.Print(cli.RenderTable(cli.Table{ + Headers: []string{"Date", "Day", "Sessions", "Prompts", "Tokens", "Cost"}, + Rows: rows, + })) + + return nil +} diff --git a/cmd/hourly.go b/cmd/hourly.go new file mode 100644 index 0000000..6225535 --- /dev/null +++ b/cmd/hourly.go @@ -0,0 +1,71 @@ +package cmd + +import ( + "fmt" + "strings" + + "cburn/internal/cli" + "cburn/internal/pipeline" + + "github.com/spf13/cobra" +) + +var hourlyCmd = &cobra.Command{ + Use: "hourly", + Short: "Activity by hour of day", + RunE: runHourly, +} + +func init() { + rootCmd.AddCommand(hourlyCmd) +} + +func runHourly(_ *cobra.Command, _ []string) error { + result, err := loadData() + if err != nil { + return err + } + if len(result.Sessions) == 0 { + fmt.Println("\n No sessions found.") + return nil + } + + filtered, since, until := applyFilters(result.Sessions) + hours := pipeline.AggregateHourly(filtered, since, until) + + fmt.Println() + fmt.Println(cli.RenderTitle(fmt.Sprintf("ACTIVITY BY HOUR Last %dd (local time)", flagDays))) + fmt.Println() + + // Find max for bar scaling + maxPrompts := 0 + for _, h := range hours { + if h.Prompts > maxPrompts { + maxPrompts = h.Prompts + } + } + + maxBarWidth := 40 + for _, h := range hours { + barLen := 0 + if maxPrompts > 0 { + barLen = h.Prompts * maxBarWidth / maxPrompts + } + bar := strings.Repeat("█", barLen) + + promptStr := cli.FormatNumber(int64(h.Prompts)) + fmt.Printf(" %02d:00 │ %6s │ %s\n", h.Hour, promptStr, bar) + } + + // Find peak hour + peakHour := 0 + for _, h := range hours { + if h.Prompts > hours[peakHour].Prompts { + peakHour = h.Hour + } + } + fmt.Printf("\n Peak: %02d:00 (%s prompts)\n\n", + peakHour, cli.FormatNumber(int64(hours[peakHour].Prompts))) + + return nil +} diff --git a/cmd/models.go b/cmd/models.go new file mode 100644 index 0000000..4389ba0 --- /dev/null +++ b/cmd/models.go @@ -0,0 +1,62 @@ +package cmd + +import ( + "fmt" + + "cburn/internal/cli" + "cburn/internal/pipeline" + + "github.com/spf13/cobra" +) + +var modelsCmd = &cobra.Command{ + Use: "models", + Short: "Model usage breakdown", + RunE: runModels, +} + +func init() { + rootCmd.AddCommand(modelsCmd) +} + +func runModels(_ *cobra.Command, _ []string) error { + result, err := loadData() + if err != nil { + return err + } + if len(result.Sessions) == 0 { + fmt.Println("\n No sessions found.") + return nil + } + + filtered, since, until := applyFilters(result.Sessions) + models := pipeline.AggregateModels(filtered, since, until) + + if len(models) == 0 { + fmt.Println("\n No model data in the selected time range.") + return nil + } + + fmt.Println() + fmt.Println(cli.RenderTitle(fmt.Sprintf("MODEL USAGE Last %dd", flagDays))) + fmt.Println() + + rows := make([][]string, 0, len(models)) + for _, ms := range models { + rows = append(rows, []string{ + shortModel(ms.Model), + cli.FormatNumber(int64(ms.APICalls)), + cli.FormatTokens(ms.InputTokens), + cli.FormatTokens(ms.OutputTokens), + cli.FormatCost(ms.EstimatedCost), + fmt.Sprintf("%.1f%%", ms.SharePercent), + }) + } + + fmt.Print(cli.RenderTable(cli.Table{ + Headers: []string{"Model", "Calls", "Input", "Output", "Cost", "Share"}, + Rows: rows, + })) + + return nil +} diff --git a/cmd/projects.go b/cmd/projects.go new file mode 100644 index 0000000..21efba4 --- /dev/null +++ b/cmd/projects.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "fmt" + + "cburn/internal/cli" + "cburn/internal/pipeline" + + "github.com/spf13/cobra" +) + +var projectsCmd = &cobra.Command{ + Use: "projects", + Short: "Project usage ranking", + RunE: runProjects, +} + +func init() { + rootCmd.AddCommand(projectsCmd) +} + +func runProjects(_ *cobra.Command, _ []string) error { + result, err := loadData() + if err != nil { + return err + } + if len(result.Sessions) == 0 { + fmt.Println("\n No sessions found.") + return nil + } + + filtered, since, until := applyFilters(result.Sessions) + projects := pipeline.AggregateProjects(filtered, since, until) + + if len(projects) == 0 { + fmt.Println("\n No project data in the selected time range.") + return nil + } + + fmt.Println() + fmt.Println(cli.RenderTitle(fmt.Sprintf("PROJECTS Last %dd", flagDays))) + fmt.Println() + + rows := make([][]string, 0, len(projects)) + for _, ps := range projects { + rows = append(rows, []string{ + truncate(ps.Project, 18), + cli.FormatNumber(int64(ps.Sessions)), + cli.FormatNumber(int64(ps.Prompts)), + cli.FormatTokens(ps.TotalTokens), + cli.FormatCost(ps.EstimatedCost), + }) + } + + fmt.Print(cli.RenderTable(cli.Table{ + Headers: []string{"Project", "Sessions", "Prompts", "Tokens", "Cost"}, + Rows: rows, + })) + + return nil +} diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..1b312e9 --- /dev/null +++ b/cmd/root.go @@ -0,0 +1,142 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "cburn/internal/cli" + "cburn/internal/model" + "cburn/internal/pipeline" + "cburn/internal/store" + + "github.com/spf13/cobra" +) + +var ( + flagDays int + flagProject string + flagModel string + flagNoCache bool + flagDataDir string + flagQuiet bool + flagNoSubagents bool +) + +var rootCmd = &cobra.Command{ + Use: "cburn", + Short: "Claude Usage Metrics CLI", + Long: "Analyze your Claude Code usage: tokens, costs, sessions, and more.", + RunE: runSummary, +} + +// Execute is the main entry point called from main.go. +func Execute() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func init() { + homeDir, _ := os.UserHomeDir() + defaultDataDir := filepath.Join(homeDir, ".claude") + + rootCmd.PersistentFlags().IntVarP(&flagDays, "days", "n", 30, "Time window in days") + rootCmd.PersistentFlags().StringVarP(&flagProject, "project", "p", "", "Filter to project (substring match)") + rootCmd.PersistentFlags().StringVarP(&flagModel, "model", "m", "", "Filter to model (substring match)") + rootCmd.PersistentFlags().BoolVar(&flagNoCache, "no-cache", false, "Skip SQLite cache, reparse everything") + rootCmd.PersistentFlags().StringVarP(&flagDataDir, "data-dir", "d", defaultDataDir, "Claude data directory") + rootCmd.PersistentFlags().BoolVarP(&flagQuiet, "quiet", "q", false, "Suppress progress output") + rootCmd.PersistentFlags().BoolVar(&flagNoSubagents, "no-subagents", false, "Exclude subagent sessions") +} + +// loadData is the shared data loading path used by all commands. +// Uses SQLite cache when available for fast subsequent runs. +func loadData() (*pipeline.LoadResult, error) { + if !flagQuiet { + fmt.Fprintf(os.Stderr, " Scanning sessions...\n") + } + + progressFn := func(current, total int) { + if flagQuiet { + return + } + if current%100 == 0 || current == total { + fmt.Fprintf(os.Stderr, "\r Parsing [%d/%d]", current, total) + } + } + + // Try cached load unless --no-cache + if !flagNoCache { + cache, err := store.Open(pipeline.CachePath()) + if err != nil { + // Cache open failed — fall back to uncached + if !flagQuiet { + fmt.Fprintf(os.Stderr, " Cache unavailable, doing full parse\n") + } + } else { + defer cache.Close() + + cr, err := pipeline.LoadWithCache(flagDataDir, !flagNoSubagents, cache, progressFn) + if err != nil { + // Cache-assisted load failed — fall back + if !flagQuiet { + fmt.Fprintf(os.Stderr, "\n Cache error, falling back to full parse\n") + } + } else { + if !flagQuiet && cr.TotalFiles > 0 { + if cr.Reparsed == 0 { + fmt.Fprintf(os.Stderr, "\r Loaded %s sessions from cache (%d projects) \n", + formatNumber(int64(len(cr.Sessions))), + cr.ProjectCount, + ) + } else { + fmt.Fprintf(os.Stderr, "\r %s cached + %d reparsed (%d projects) \n", + formatNumber(int64(cr.CacheHits)), + cr.Reparsed, + cr.ProjectCount, + ) + } + } + return &cr.LoadResult, nil + } + } + } + + // Uncached path + result, err := pipeline.Load(flagDataDir, !flagNoSubagents, progressFn) + if err != nil { + return nil, err + } + + if !flagQuiet && result.TotalFiles > 0 { + fmt.Fprintf(os.Stderr, "\r Parsed %s sessions across %d projects \n", + formatNumber(int64(result.ParsedFiles)), + result.ProjectCount, + ) + } + + return result, nil +} + +// applyFilters returns filtered sessions and the computed time range. +func applyFilters(sessions []model.SessionStats) ([]model.SessionStats, time.Time, time.Time) { + now := time.Now() + since := now.AddDate(0, 0, -flagDays) + until := now + + filtered := sessions + if flagProject != "" { + filtered = pipeline.FilterByProject(filtered, flagProject) + } + if flagModel != "" { + filtered = pipeline.FilterByModel(filtered, flagModel) + } + + return filtered, since, until +} + +func formatNumber(n int64) string { + return cli.FormatNumber(n) +} diff --git a/cmd/sessions.go b/cmd/sessions.go new file mode 100644 index 0000000..2bb6e98 --- /dev/null +++ b/cmd/sessions.go @@ -0,0 +1,96 @@ +package cmd + +import ( + "fmt" + "sort" + + "cburn/internal/cli" + "cburn/internal/pipeline" + + "github.com/spf13/cobra" +) + +var sessionsCmd = &cobra.Command{ + Use: "sessions", + Short: "Session list with details", + RunE: runSessions, +} + +var sessionsLimit int + +func init() { + sessionsCmd.Flags().IntVarP(&sessionsLimit, "limit", "l", 20, "Number of sessions to show") + rootCmd.AddCommand(sessionsCmd) +} + +func runSessions(_ *cobra.Command, _ []string) error { + result, err := loadData() + if err != nil { + return err + } + if len(result.Sessions) == 0 { + fmt.Println("\n No sessions found.") + return nil + } + + filtered, since, until := applyFilters(result.Sessions) + sessions := pipeline.FilterByTime(filtered, since, until) + + if len(sessions) == 0 { + fmt.Println("\n No sessions in the selected time range.") + return nil + } + + // Sort by start time descending + sort.Slice(sessions, func(i, j int) bool { + return sessions[i].StartTime.After(sessions[j].StartTime) + }) + + // Limit + if sessionsLimit > 0 && len(sessions) > sessionsLimit { + sessions = sessions[:sessionsLimit] + } + + fmt.Println() + fmt.Println(cli.RenderTitle(fmt.Sprintf("SESSIONS Last %dd (showing %d)", flagDays, len(sessions)))) + fmt.Println() + + rows := make([][]string, 0, len(sessions)) + for _, s := range sessions { + startStr := "" + if !s.StartTime.IsZero() { + startStr = s.StartTime.Local().Format("Jan 02 15:04") + } + + totalTokens := s.InputTokens + s.OutputTokens + + s.CacheCreation5mTokens + s.CacheCreation1hTokens + + project := s.Project + if s.IsSubagent { + project += " (sub)" + } + + rows = append(rows, []string{ + startStr, + truncate(project, 14), + cli.FormatDuration(s.DurationSecs), + cli.FormatTokens(totalTokens), + cli.FormatCost(s.EstimatedCost), + }) + } + + fmt.Print(cli.RenderTable(cli.Table{ + Headers: []string{"Start", "Project", "Duration", "Tokens", "Cost"}, + Rows: rows, + })) + + return nil +} + +func truncate(s string, maxLen int) string { + runes := []rune(s) + if len(runes) <= maxLen { + return s + } + return string(runes[:maxLen-1]) + "…" +} diff --git a/cmd/setup.go b/cmd/setup.go new file mode 100644 index 0000000..8430b35 --- /dev/null +++ b/cmd/setup.go @@ -0,0 +1,117 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "strings" + + "cburn/internal/config" + "cburn/internal/source" + + "github.com/spf13/cobra" +) + +var setupCmd = &cobra.Command{ + Use: "setup", + Short: "First-time setup wizard", + RunE: runSetup, +} + +func init() { + rootCmd.AddCommand(setupCmd) +} + +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() + if len(files) > 0 { + fmt.Printf(" Found %s sessions in %s (%d projects)\n\n", + formatNumber(int64(len(files))), flagDataDir, projectCount) + } + + // 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)) + } + 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" + } + + // Save + 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.Println(" Run `cburn setup` anytime to reconfigure.") + fmt.Println() + + return nil +} + +func maskAPIKey(key string) string { + if len(key) > 16 { + return key[:8] + "..." + key[len(key)-4:] + } + if len(key) > 4 { + return key[:4] + "..." + } + return "****" +} diff --git a/cmd/summary.go b/cmd/summary.go new file mode 100644 index 0000000..0466b67 --- /dev/null +++ b/cmd/summary.go @@ -0,0 +1,95 @@ +package cmd + +import ( + "fmt" + "os" + + "cburn/internal/cli" + "cburn/internal/pipeline" + + "github.com/spf13/cobra" +) + +var summaryCmd = &cobra.Command{ + Use: "summary", + Short: "Detailed usage summary with costs", + RunE: runSummary, +} + +func init() { + rootCmd.AddCommand(summaryCmd) +} + +func runSummary(_ *cobra.Command, _ []string) error { + result, err := loadData() + if err != nil { + return err + } + + if len(result.Sessions) == 0 { + fmt.Println("\n No Claude Code sessions found.") + fmt.Println(" Use Claude Code first, then come back!") + return nil + } + + filtered, since, until := applyFilters(result.Sessions) + stats := pipeline.Aggregate(filtered, since, until) + + if stats.TotalSessions == 0 { + fmt.Println("\n No sessions found in the selected time range.") + return nil + } + + // Compute previous period for comparison + prevDuration := until.Sub(since) + prevSince := since.Add(-prevDuration) + prevStats := pipeline.Aggregate(filtered, prevSince, since) + + // Render output + fmt.Println() + fmt.Println(cli.RenderTitle(fmt.Sprintf("CLAUDE USAGE Last %dd", flagDays))) + fmt.Println() + + // Build the summary table + rows := [][]string{ + {"Sessions", cli.FormatNumber(int64(stats.TotalSessions))}, + {"Prompts", cli.FormatNumber(int64(stats.TotalPrompts))}, + {"Total Time", cli.FormatDuration(stats.TotalDurationSecs)}, + {"---"}, + {"Input Tokens", cli.FormatTokens(stats.InputTokens)}, + {"Output Tokens", cli.FormatTokens(stats.OutputTokens)}, + {"Cache Write (5m)", cli.FormatTokens(stats.CacheCreation5mTokens)}, + {"Cache Write (1h)", cli.FormatTokens(stats.CacheCreation1hTokens)}, + {"Cache Read", cli.FormatTokens(stats.CacheReadTokens)}, + {"Total Billed", cli.FormatTokens(stats.TotalBilledTokens)}, + {"---"}, + {"Cost (est)", cli.FormatCost(stats.EstimatedCost)}, + {"Cache Savings", cli.FormatCost(stats.CacheSavings)}, + {"Cache Hit Rate", cli.FormatPercent(stats.CacheHitRate)}, + {"---"}, + } + + // Cost per day with delta + costDayStr := fmt.Sprintf("%s/day", cli.FormatCost(stats.CostPerDay)) + if prevStats.CostPerDay > 0 { + costDayStr += fmt.Sprintf(" (%s vs prev %dd)", + cli.FormatDelta(stats.CostPerDay, prevStats.CostPerDay), flagDays) + } + rows = append(rows, []string{"Cost/day", costDayStr}) + rows = append(rows, []string{"Tokens/day", cli.FormatTokens(stats.TokensPerDay)}) + rows = append(rows, []string{"Sessions/day", fmt.Sprintf("%.1f", stats.SessionsPerDay)}) + + table := cli.Table{ + Headers: []string{"Metric", "Value"}, + Rows: rows, + } + + fmt.Print(cli.RenderTable(table)) + + // Print warnings + if result.FileErrors > 0 { + fmt.Fprintf(os.Stderr, "\n %d files could not be parsed\n", result.FileErrors) + } + + return nil +} diff --git a/cmd/tui.go b/cmd/tui.go new file mode 100644 index 0000000..96f080a --- /dev/null +++ b/cmd/tui.go @@ -0,0 +1,37 @@ +package cmd + +import ( + "fmt" + + "cburn/internal/config" + "cburn/internal/tui" + "cburn/internal/tui/theme" + + tea "github.com/charmbracelet/bubbletea" + "github.com/spf13/cobra" +) + +var tuiCmd = &cobra.Command{ + Use: "tui", + Short: "Launch interactive TUI dashboard", + RunE: runTUI, +} + +func init() { + rootCmd.AddCommand(tuiCmd) +} + +func runTUI(_ *cobra.Command, _ []string) error { + // Load config for theme + cfg, _ := config.Load() + theme.SetActive(cfg.Appearance.Theme) + + app := tui.NewApp(flagDataDir, flagDays, flagProject, flagModel, !flagNoSubagents) + p := tea.NewProgram(app, tea.WithAltScreen()) + + if _, err := p.Run(); err != nil { + return fmt.Errorf("TUI error: %w", err) + } + + return nil +}