Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

/.direnv/
/build/
/tmp/
/tapes
.DS_Store

# JetBrains IDEs
Expand Down
198 changes: 198 additions & 0 deletions cmd/tapes/deck/deck.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// Package deckcmder provides the deck command for session ROI dashboards.
package deckcmder

import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"

"github.com/spf13/cobra"

"github.com/papercomputeco/tapes/pkg/deck"
)

const deckLongDesc string = `Deck is an ROI dashboard for agent sessions.

Summarize recent sessions with a TUI and drill down into a single session.

Examples:
tapes deck
tapes deck --since 24h
tapes deck --from 2026-01-30 --to 2026-01-31
tapes deck --sort cost --model claude-sonnet-4.5
tapes deck --session sess_a8f2c1d3
tapes deck --web
tapes deck --web --port 9999
tapes deck --pricing ./pricing.json
`

const deckShortDesc string = "Deck - ROI dashboard for agent sessions"

type deckCommander struct {
sqlitePath string
pricingPath string
since string
from string
to string
sort string
model string
status string
session string
web bool
port int
}

func NewDeckCmd() *cobra.Command {
cmder := &deckCommander{}

cmd := &cobra.Command{
Use: "deck",
Short: deckShortDesc,
Long: deckLongDesc,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return cmder.run(cmd.Context())
},
}

cmd.Flags().StringVarP(&cmder.sqlitePath, "sqlite", "s", "", "Path to SQLite database")
cmd.Flags().StringVar(&cmder.pricingPath, "pricing", "", "Path to pricing JSON overrides")
cmd.Flags().StringVar(&cmder.since, "since", "", "Look back duration (e.g. 24h)")
cmd.Flags().StringVar(&cmder.from, "from", "", "Start time (YYYY-MM-DD or RFC3339)")
cmd.Flags().StringVar(&cmder.to, "to", "", "End time (YYYY-MM-DD or RFC3339)")
cmd.Flags().StringVar(&cmder.sort, "sort", "cost", "Sort sessions by cost|time|tokens|duration")
cmd.Flags().StringVar(&cmder.model, "model", "", "Filter by model")
cmd.Flags().StringVar(&cmder.status, "status", "", "Filter by status (completed|failed|abandoned)")
cmd.Flags().StringVar(&cmder.session, "session", "", "Drill into a specific session ID")
cmd.Flags().BoolVar(&cmder.web, "web", false, "Serve the web dashboard locally")
cmd.Flags().IntVar(&cmder.port, "port", 8888, "Web server port")

return cmd
}

func (c *deckCommander) run(ctx context.Context) error {
pricing, err := deck.LoadPricing(c.pricingPath)
if err != nil {
return err
}

sqlitePath, err := resolveSQLitePath(c.sqlitePath)
if err != nil {
return err
}

query, closeFn, err := deck.NewQuery(sqlitePath, pricing)
if err != nil {
return err
}
defer closeFn()

filters, err := c.parseFilters()
if err != nil {
return err
}

if c.web {
return runDeckWeb(ctx, query, filters, c.port)
}

return runDeckTUI(ctx, query, filters)
}

func (c *deckCommander) parseFilters() (deck.Filters, error) {
filters := deck.Filters{
Sort: strings.ToLower(strings.TrimSpace(c.sort)),
Model: strings.TrimSpace(c.model),
Status: strings.TrimSpace(c.status),
Session: strings.TrimSpace(c.session),
}

if c.since != "" {
duration, err := time.ParseDuration(c.since)
if err != nil {
return filters, fmt.Errorf("invalid since duration: %w", err)
}
filters.Since = duration
}

if c.from != "" {
parsed, err := parseTime(c.from)
if err != nil {
return filters, fmt.Errorf("invalid from time: %w", err)
}
filters.From = &parsed
}

if c.to != "" {
parsed, err := parseTime(c.to)
if err != nil {
return filters, fmt.Errorf("invalid to time: %w", err)
}
filters.To = &parsed
}

return filters, nil
}

func parseTime(value string) (time.Time, error) {
value = strings.TrimSpace(value)
if value == "" {
return time.Time{}, fmt.Errorf("empty time")
}

if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed, nil
}

if parsed, err := time.Parse("2006-01-02", value); err == nil {
return parsed, nil
}

return time.Time{}, fmt.Errorf("expected RFC3339 or YYYY-MM-DD")
}

func resolveSQLitePath(override string) (string, error) {
if override != "" {
return override, nil
}

if envPath := strings.TrimSpace(os.Getenv("TAPES_SQLITE")); envPath != "" {
return envPath, nil
}
if envPath := strings.TrimSpace(os.Getenv("TAPES_DB")); envPath != "" {
return envPath, nil
}

candidates := []string{
"tapes.db",
"tapes.sqlite",
filepath.Join(".tapes", "tapes.db"),
filepath.Join(".tapes", "tapes.sqlite"),
}

home, err := os.UserHomeDir()
if err == nil {
candidates = append([]string{
filepath.Join(home, ".tapes", "tapes.db"),
filepath.Join(home, ".tapes", "tapes.sqlite"),
}, candidates...)
}

if xdgHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); xdgHome != "" {
candidates = append([]string{
filepath.Join(xdgHome, "tapes", "tapes.db"),
filepath.Join(xdgHome, "tapes", "tapes.sqlite"),
}, candidates...)
}

for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}

return "", fmt.Errorf("could not find tapes SQLite database; pass --sqlite")
}
13 changes: 13 additions & 0 deletions cmd/tapes/deck/deck_suite_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package deckcmder

import (
"testing"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

func TestDeckCommander(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Deck Commander Suite")
}
74 changes: 74 additions & 0 deletions cmd/tapes/deck/deck_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package deckcmder

import (
"os"
"path/filepath"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)

var _ = Describe("resolveSQLitePath", func() {
var (
origHome string
origXDG string
origTapesDB string
origTapesSQ string
origCwd string
)

BeforeEach(func() {
origHome = os.Getenv("HOME")
origXDG = os.Getenv("XDG_DATA_HOME")
origTapesDB = os.Getenv("TAPES_DB")
origTapesSQ = os.Getenv("TAPES_SQLITE")
var err error
origCwd, err = os.Getwd()
Expect(err).NotTo(HaveOccurred())
})

AfterEach(func() {
Expect(os.Setenv("HOME", origHome)).To(Succeed())
Expect(os.Setenv("XDG_DATA_HOME", origXDG)).To(Succeed())
Expect(os.Setenv("TAPES_DB", origTapesDB)).To(Succeed())
Expect(os.Setenv("TAPES_SQLITE", origTapesSQ)).To(Succeed())
Expect(os.Chdir(origCwd)).To(Succeed())
})

It("prefers TAPES_SQLITE when set", func() {
Expect(os.Setenv("TAPES_SQLITE", "/tmp/custom.db")).To(Succeed())
Expect(os.Setenv("TAPES_DB", "")).To(Succeed())

path, err := resolveSQLitePath("")
Expect(err).NotTo(HaveOccurred())
Expect(path).To(Equal("/tmp/custom.db"))
})

It("resolves ~/.tapes/tapes.db when present", func() {
homeDir, err := os.MkdirTemp("", "tapes-home-*")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() {
_ = os.RemoveAll(homeDir)
})

tmpDir, err := os.MkdirTemp("", "tapes-cwd-*")
Expect(err).NotTo(HaveOccurred())
DeferCleanup(func() {
_ = os.RemoveAll(tmpDir)
})

Expect(os.Setenv("HOME", homeDir)).To(Succeed())
Expect(os.Setenv("XDG_DATA_HOME", "")).To(Succeed())
Expect(os.Setenv("TAPES_DB", "")).To(Succeed())
Expect(os.Setenv("TAPES_SQLITE", "")).To(Succeed())
Expect(os.Chdir(tmpDir)).To(Succeed())

dbPath := filepath.Join(homeDir, ".tapes", "tapes.db")
Expect(os.MkdirAll(filepath.Dir(dbPath), 0o755)).To(Succeed())
Expect(os.WriteFile(dbPath, []byte("test"), 0o644)).To(Succeed())

path, err := resolveSQLitePath("")
Expect(err).NotTo(HaveOccurred())
Expect(path).To(Equal(dbPath))
})
})
Loading