feat: add CLI commands for usage analysis, cost breakdown, and setup
Implement the complete Cobra command tree (11 subcommands): - cmd/root.go: Root command with persistent flags (--days, --project, --model, --no-cache, --data-dir, --quiet, --no-subagents). Shared loadData() orchestrates the full pipeline: tries cache-assisted loading first, falls back to uncached parse on cache failure, reports progress to stderr. applyFilters() applies project/model substring filters and computes the time window. - cmd/summary.go: Default command (also "cburn summary"). Renders a bordered metrics table with token breakdown (5 types), cost with cache savings, and per-day rates with period-over-period deltas. - cmd/costs.go: Detailed cost analysis — breaks down costs by token type (output, cache_write_1h, input, cache_write_5m, cache_read) with share percentages, period comparison bar chart, and per-model cost breakdown (input/output/cache/total columns). - cmd/daily.go: Daily usage table (date, weekday, sessions, prompts, tokens, cost) sorted most-recent-first. - cmd/hourly.go: Activity heatmap showing prompt distribution across 24 hours with Unicode block bars, reports peak hour. - cmd/models.go: Model usage ranking with API call counts, token volumes, cost, and usage share percentage. - cmd/projects.go: Project ranking by cost with session/prompt/token counts. - cmd/sessions.go: Session list sorted by recency with --limit flag (default 20). Shows start time, project, duration, tokens, cost. Marks subagent sessions with "(sub)" suffix. - cmd/config_cmd.go: Displays current configuration across all sections (general, admin API, appearance, budget) with auto- detected plan ceiling. - cmd/setup.go: Interactive first-run wizard — configures Admin API key, default time range (7/30/90 days), and color theme (Flexoki Dark, Catppuccin Mocha, Tokyo Night, Terminal). Saves to ~/.config/cburn/config.toml. - cmd/tui.go: Launches the interactive Bubble Tea TUI dashboard, passing through all filter flags and applying the configured theme. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
69
cmd/config_cmd.go
Normal file
69
cmd/config_cmd.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
164
cmd/costs.go
Normal file
164
cmd/costs.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
|
||||||
62
cmd/daily.go
Normal file
62
cmd/daily.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
71
cmd/hourly.go
Normal file
71
cmd/hourly.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
62
cmd/models.go
Normal file
62
cmd/models.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
61
cmd/projects.go
Normal file
61
cmd/projects.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
142
cmd/root.go
Normal file
142
cmd/root.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
96
cmd/sessions.go
Normal file
96
cmd/sessions.go
Normal file
@@ -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]) + "…"
|
||||||
|
}
|
||||||
117
cmd/setup.go
Normal file
117
cmd/setup.go
Normal file
@@ -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 "****"
|
||||||
|
}
|
||||||
95
cmd/summary.go
Normal file
95
cmd/summary.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
37
cmd/tui.go
Normal file
37
cmd/tui.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user