cli/internal/cmd/session/cmd_session.go

240 lines
5.5 KiB
Go
Raw Normal View History

// Package session provides commands for replaying and searching Claude Code session transcripts.
package session
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/host-uk/core/pkg/cli"
"github.com/host-uk/core/pkg/session"
)
func init() {
cli.RegisterCommands(AddSessionCommands)
}
// AddSessionCommands registers the 'session' command group.
func AddSessionCommands(root *cli.Command) {
sessionCmd := &cli.Command{
Use: "session",
Short: "Session recording and replay",
}
root.AddCommand(sessionCmd)
addListCommand(sessionCmd)
addReplayCommand(sessionCmd)
addSearchCommand(sessionCmd)
}
func projectsDir() string {
home, _ := os.UserHomeDir()
// Walk .claude/projects/ looking for dirs with .jsonl files
base := filepath.Join(home, ".claude", "projects")
entries, err := os.ReadDir(base)
if err != nil {
return base
}
// Return the first project dir that has .jsonl files
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(base, e.Name())
matches, _ := filepath.Glob(filepath.Join(dir, "*.jsonl"))
if len(matches) > 0 {
return dir
}
}
return base
}
func addListCommand(parent *cli.Command) {
listCmd := &cli.Command{
Use: "list",
Short: "List recent sessions",
RunE: func(cmd *cli.Command, args []string) error {
sessions, err := session.ListSessions(projectsDir())
if err != nil {
return err
}
if len(sessions) == 0 {
cli.Print("No sessions found")
return nil
}
cli.Print(cli.HeaderStyle.Render("Recent Sessions"))
cli.Print("")
for i, s := range sessions {
if i >= 20 {
cli.Print(cli.DimStyle.Render(fmt.Sprintf(" ... and %d more", len(sessions)-20)))
break
}
dur := s.EndTime.Sub(s.StartTime)
durStr := ""
if dur > 0 {
durStr = fmt.Sprintf(" (%s)", formatDur(dur))
}
id := s.ID
if len(id) > 8 {
id = id[:8]
}
cli.Print(fmt.Sprintf(" %s %s%s",
cli.ValueStyle.Render(id),
s.StartTime.Format("2006-01-02 15:04"),
cli.DimStyle.Render(durStr)))
}
return nil
},
}
parent.AddCommand(listCmd)
}
func addReplayCommand(parent *cli.Command) {
var mp4 bool
var output string
replayCmd := &cli.Command{
Use: "replay <session-id>",
Short: "Generate HTML timeline (and optional MP4) from a session",
Args: cli.MinimumNArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
id := args[0]
path := findSession(id)
if path == "" {
return fmt.Errorf("session not found: %s", id)
}
cli.Print(fmt.Sprintf("Parsing %s...", cli.ValueStyle.Render(filepath.Base(path))))
sess, err := session.ParseTranscript(path)
if err != nil {
return fmt.Errorf("parse: %w", err)
}
toolCount := 0
for _, e := range sess.Events {
if e.Type == "tool_use" {
toolCount++
}
}
cli.Print(fmt.Sprintf(" %d events, %d tool calls",
len(sess.Events), toolCount))
// HTML output
htmlPath := output
if htmlPath == "" {
htmlPath = fmt.Sprintf("session-%s.html", shortID(sess.ID))
}
if err := session.RenderHTML(sess, htmlPath); err != nil {
return fmt.Errorf("render html: %w", err)
}
cli.Print(cli.SuccessStyle.Render(fmt.Sprintf(" HTML: %s", htmlPath)))
// MP4 output
if mp4 {
mp4Path := strings.TrimSuffix(htmlPath, ".html") + ".mp4"
if err := session.RenderMP4(sess, mp4Path); err != nil {
cli.Print(cli.ErrorStyle.Render(fmt.Sprintf(" MP4: %s", err)))
} else {
cli.Print(cli.SuccessStyle.Render(fmt.Sprintf(" MP4: %s", mp4Path)))
}
}
return nil
},
}
replayCmd.Flags().BoolVar(&mp4, "mp4", false, "Also generate MP4 video (requires vhs + ffmpeg)")
replayCmd.Flags().StringVarP(&output, "output", "o", "", "Output file path")
parent.AddCommand(replayCmd)
}
func addSearchCommand(parent *cli.Command) {
searchCmd := &cli.Command{
Use: "search <query>",
Short: "Search across session transcripts",
Args: cli.MinimumNArgs(1),
RunE: func(cmd *cli.Command, args []string) error {
query := strings.ToLower(strings.Join(args, " "))
results, err := session.Search(projectsDir(), query)
if err != nil {
return err
}
if len(results) == 0 {
cli.Print("No matches found")
return nil
}
cli.Print(cli.HeaderStyle.Render(fmt.Sprintf("Found %d matches", len(results))))
cli.Print("")
for _, r := range results {
id := r.SessionID
if len(id) > 8 {
id = id[:8]
}
cli.Print(fmt.Sprintf(" %s %s %s",
cli.ValueStyle.Render(id),
r.Timestamp.Format("15:04:05"),
cli.DimStyle.Render(r.Tool)))
cli.Print(fmt.Sprintf(" %s", truncateStr(r.Match, 100)))
cli.Print("")
}
return nil
},
}
parent.AddCommand(searchCmd)
}
func findSession(id string) string {
dir := projectsDir()
// Try exact match first
path := filepath.Join(dir, id+".jsonl")
if _, err := os.Stat(path); err == nil {
return path
}
// Try prefix match
matches, _ := filepath.Glob(filepath.Join(dir, id+"*.jsonl"))
if len(matches) == 1 {
return matches[0]
}
return ""
}
func shortID(id string) string {
if len(id) > 8 {
return id[:8]
}
return id
}
func formatDur(d interface {
Hours() float64
Minutes() float64
Seconds() float64
}) string {
type dur interface {
Hours() float64
Minutes() float64
Seconds() float64
}
dd := d.(dur)
h := int(dd.Hours())
m := int(dd.Minutes()) % 60
if h > 0 {
return fmt.Sprintf("%dh%dm", h, m)
}
s := int(dd.Seconds()) % 60
if m > 0 {
return fmt.Sprintf("%dm%ds", m, s)
}
return fmt.Sprintf("%ds", s)
}
func truncateStr(s string, max int) string {
if len(s) <= max {
return s
}
return s[:max] + "..."
}