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:
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user