diff --git a/.golangci.yaml b/.golangci.yaml index ec30e6afb8..7604872e09 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -115,6 +115,9 @@ linters: - github.com/go-git/go-git/v6/plumbing.EncodedObject - github.com/go-git/go-git/v6/storage.Storer - github.com/go-git/go-git/v6/plumbing/storer.EncodedObjectIter + - github.com/go-git/go-git/v6/x/plugin.Signer + - golang.org/x/crypto/ssh/agent.Agent + - github.com/entireio/cli/cmd/entire/cli/summarize.Generator - github.com/entireio/cli/e2e/agents.Session - github.com/go-git/go-billy/v6.Filesystem - golang.org/x/crypto/ssh/agent.Agent diff --git a/cmd/entire/cli/recap.go b/cmd/entire/cli/recap.go new file mode 100644 index 0000000000..b1189264fa --- /dev/null +++ b/cmd/entire/cli/recap.go @@ -0,0 +1,159 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "strings" + "time" + + "github.com/mattn/go-isatty" + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/entireio/cli/cmd/entire/cli/gitremote" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/recap" +) + +type recapFlags struct { + day, week, month, d90 bool + view string + agent string + color string + insecureHTTP bool +} + +const ( + recapColorAuto = "auto" + recapColorAlways = "always" + recapColorNever = "never" +) + +func newRecapCmd() *cobra.Command { + f := &recapFlags{view: string(recap.ViewBoth), agent: recap.AgentAll, color: recapColorAuto} + cmd := &cobra.Command{ + Use: "recap", + Short: "Summarize recent checkpoint activity", + RunE: func(cmd *cobra.Command, _ []string) error { + return runRecap(cmd.Context(), cmd.OutOrStdout(), f) + }, + } + cmd.Flags().BoolVar(&f.day, "day", false, "Today only (default)") + cmd.Flags().BoolVar(&f.week, "week", false, "Last 7 days") + cmd.Flags().BoolVar(&f.month, "month", false, "This calendar month") + cmd.Flags().BoolVar(&f.d90, "90", false, "Rolling 90 days") + cmd.Flags().StringVar(&f.agent, "agent", recap.AgentAll, "Agent id to show, or all") + cmd.Flags().StringVar(&f.view, "view", string(recap.ViewBoth), "Which columns to show: you, team, or both") + cmd.Flags().StringVar(&f.color, "color", recapColorAuto, "Color output: auto, always, or never") + cmd.Flags().BoolVar(&f.insecureHTTP, "insecure-http-auth", false, "Allow plain-HTTP auth (local dev only)") + cmd.MarkFlagsMutuallyExclusive("day", "week", "month", "90") + return cmd +} + +func (f *recapFlags) rangeKey() recap.RangeKey { + switch { + case f.week: + return recap.RangeWeek + case f.month: + return recap.RangeMonth + case f.d90: + return recap.Range90d + default: + return recap.RangeDay + } +} + +func (f *recapFlags) mode() recap.ViewMode { + switch strings.ToLower(strings.TrimSpace(f.view)) { + case "you", "me": + return recap.ViewYou + case "team", "contributors": + return recap.ViewTeam + case "both", "": + return recap.ViewBoth + default: + return recap.ViewMode(f.view) + } +} + +func (f *recapFlags) agentName() string { + agent := strings.ToLower(strings.TrimSpace(f.agent)) + if agent == "" { + return recap.AgentAll + } + return agent +} + +func (f *recapFlags) colorEnabled(w io.Writer) (bool, error) { + switch strings.ToLower(strings.TrimSpace(f.color)) { + case "", recapColorAuto: + return shouldUseColor(w) && !IsAccessibleMode(), nil + case recapColorAlways: + return true, nil + case recapColorNever: + return false, nil + default: + return false, fmt.Errorf("invalid --color %q (use auto, always, or never)", f.color) + } +} + +func runRecap(ctx context.Context, w io.Writer, f *recapFlags) error { + if _, err := paths.WorktreeRoot(ctx); err != nil { + fmt.Fprintln(w, "Not a git repository. Run 'entire recap' from within a git repository.") + return NewSilentError(errors.New("not a git repository")) + } + mode := f.mode() + if !mode.Valid() { + return fmt.Errorf("invalid --view %q (use you, team, or both)", f.view) + } + color, err := f.colorEnabled(w) + if err != nil { + return err + } + client, err := NewAuthenticatedAPIClient(f.insecureHTTP) + if err != nil { + fmt.Fprintln(w, "Sign in with `entire login` to use `entire recap`.") + return NewSilentError(err) + } + rangeKey := f.rangeKey() + start, end := rangeKey.Bounds(time.Now()) + resp, err := recap.FetchMeRecap(ctx, client, start, end, currentRepoSlug(ctx), 0) + if err != nil { + return fmt.Errorf("fetch recap: %w", err) + } + fmt.Fprint(w, recap.RenderStaticRecap(resp, recap.RenderOptions{ + Range: rangeKey, + View: mode, + Agent: f.agentName(), + Width: terminalWidth(w), + Color: color, + })) + fmt.Fprintln(w) + return nil +} + +func terminalWidth(w io.Writer) int { + file, ok := w.(*os.File) + if !ok { + return recap.DefaultWidth + } + if !isatty.IsTerminal(file.Fd()) { + return recap.DefaultWidth + } + width, _, err := term.GetSize(int(file.Fd())) //nolint:gosec // fd values fit in int on supported platforms + if err != nil || width <= 0 { + return recap.DefaultWidth + } + return width +} + +func currentRepoSlug(ctx context.Context) string { + _, owner, repoName, err := gitremote.ResolveRemoteRepo(ctx, "origin") + if err != nil || owner == "" || repoName == "" { + return "" + } + return owner + "/" + repoName +} diff --git a/cmd/entire/cli/recap/doc.go b/cmd/entire/cli/recap/doc.go new file mode 100644 index 0000000000..3d85d4e41d --- /dev/null +++ b/cmd/entire/cli/recap/doc.go @@ -0,0 +1,3 @@ +// Package recap contains the server-backed data types and static renderer +// behind `entire recap`. +package recap diff --git a/cmd/entire/cli/recap/me_recap.go b/cmd/entire/cli/recap/me_recap.go new file mode 100644 index 0000000000..5e551ee3f2 --- /dev/null +++ b/cmd/entire/cli/recap/me_recap.go @@ -0,0 +1,138 @@ +package recap + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/url" + "strconv" + "time" + + "github.com/entireio/cli/cmd/entire/cli/api" +) + +// MeRecapResponse mirrors GET /api/v1/me/recap. +type MeRecapResponse struct { + Timeframe string `json:"timeframe"` + Repo *string `json:"repo"` + Since string `json:"since"` + Until string `json:"until"` + Agents map[string]AgentEntry `json:"agents"` + Summary Summary `json:"summary"` + Contributors *ContribSummary `json:"contributors"` + Daily []DailyCount `json:"daily"` + UpdatedAt string `json:"updated_at"` +} + +// Summary contains top-level counts intended for CLI rendering. +type Summary struct { + Me SummaryTotals `json:"me"` + Team *SummaryTotals `json:"team"` + RepoCount int `json:"repoCount"` + ActiveDays int `json:"activeDays"` + Analysis AnalysisStatus `json:"analysis"` +} + +type SummaryTotals struct { + Sessions int `json:"sessions"` + Checkpoints int `json:"checkpoints"` + Tokens int `json:"tokens"` +} + +type AnalysisStatus struct { + Complete int `json:"complete"` + Pending int `json:"pending"` + Failed int `json:"failed"` +} + +type DailyCount struct { + Date string `json:"date"` + Count int `json:"count"` +} + +type AgentEntry struct { + AgentID string `json:"agentId"` + AgentLabel string `json:"agentLabel"` + Me AgentAggregate `json:"me"` + Contributors *AgentAggregate `json:"contributors"` +} + +type AgentAggregate struct { + Sessions int `json:"sessions"` + Checkpoints int `json:"checkpoints"` + Tokens int `json:"tokens"` + TranscriptTokens int `json:"transcriptTokens"` + FilesChanged int `json:"filesChanged"` + Labels []LabelCount `json:"labels"` + Skills []SkillCount `json:"skills"` + MCPServers []McpCount `json:"mcpServers"` + ToolMix ToolMix `json:"toolMix"` +} + +type LabelCount struct { + Label string `json:"label"` + Count int `json:"count"` +} + +type SkillCount struct { + Skill string `json:"skill"` + Count int `json:"count"` +} + +type McpCount struct { + Name string `json:"name"` + Count int `json:"count"` +} + +type ToolMix struct { + Shell int `json:"shell"` + FileOps int `json:"fileOps"` + Search int `json:"search"` + MCP int `json:"mcp"` + Agent int `json:"agent"` + Other int `json:"other"` +} + +type ContribSummary struct { + DistinctUsers int `json:"distinctUsers"` + TotalTokens int `json:"totalTokens"` + TotalCheckpoints int `json:"totalCheckpoints"` +} + +// FetchMeRecap fetches one server-backed recap window. +func FetchMeRecap( + ctx context.Context, + client *api.Client, + since, until time.Time, + repo string, + limit int, +) (*MeRecapResponse, error) { + if client == nil { + return nil, errors.New("me/recap: nil client") + } + q := url.Values{} + q.Set("since", since.UTC().Format(time.RFC3339)) + q.Set("until", until.UTC().Format(time.RFC3339)) + if repo != "" { + q.Set("repo", repo) + } + if limit > 0 { + q.Set("limit", strconv.Itoa(limit)) + } + resp, err := client.Get(ctx, "/api/v1/me/recap?"+q.Encode()) + if err != nil { + return nil, fmt.Errorf("me/recap get: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(resp.Body) //nolint:errcheck // best-effort error body + return nil, fmt.Errorf("me/recap: http %d: %s", resp.StatusCode, string(body)) + } + var out MeRecapResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, fmt.Errorf("me/recap decode: %w", err) + } + return &out, nil +} diff --git a/cmd/entire/cli/recap/model.go b/cmd/entire/cli/recap/model.go new file mode 100644 index 0000000000..a96670d85f --- /dev/null +++ b/cmd/entire/cli/recap/model.go @@ -0,0 +1,62 @@ +package recap + +import "time" + +// RangeKey names the static recap windows supported by `entire recap`. +type RangeKey string + +const ( + RangeDay RangeKey = "day" + RangeWeek RangeKey = "week" + RangeMonth RangeKey = "month" + Range90d RangeKey = "90d" +) + +// Title returns the panel title for a range. +func (r RangeKey) Title() string { + switch r { + case RangeDay: + return "Today" + case RangeWeek: + return "Last 7 days" + case RangeMonth: + return "This month" + case Range90d: + return "Last 90 days" + default: + return "Today" + } +} + +// Bounds returns a half-open [start, end) window in the user's local time. +func (r RangeKey) Bounds(now time.Time) (time.Time, time.Time) { + dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + dayEnd := dayStart.AddDate(0, 0, 1) + switch r { + case RangeDay: + return dayStart, dayEnd + case RangeWeek: + return dayEnd.AddDate(0, 0, -7), dayEnd + case RangeMonth: + monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + return monthStart, monthStart.AddDate(0, 1, 0) + case Range90d: + return dayEnd.AddDate(0, 0, -90), dayEnd + default: + return dayStart, dayEnd + } +} + +// ViewMode selects which columns render in the static agents panel. +type ViewMode string + +const ( + ViewYou ViewMode = "you" + ViewTeam ViewMode = "team" + ViewBoth ViewMode = "both" +) + +// Valid reports whether the mode is one of the supported static modes. +func (m ViewMode) Valid() bool { + return m == ViewYou || m == ViewTeam || m == ViewBoth +} diff --git a/cmd/entire/cli/recap/render_static.go b/cmd/entire/cli/recap/render_static.go new file mode 100644 index 0000000000..e8509a3b6b --- /dev/null +++ b/cmd/entire/cli/recap/render_static.go @@ -0,0 +1,592 @@ +package recap + +import ( + "fmt" + "math" + "sort" + "strconv" + "strings" + + "github.com/charmbracelet/x/ansi" +) + +const ( + DefaultWidth = 78 + AgentAll = "all" + minWidth = 60 + noteAnalysis = "Labels require server analysis (may take a few minutes after committing)." +) + +type RenderOptions struct { + Range RangeKey + View ViewMode + Agent string + Width int + Color bool +} + +// RenderStaticRecap renders the server-backed static recap view. +func RenderStaticRecap(resp *MeRecapResponse, opts RenderOptions) string { + if resp == nil { + resp = &MeRecapResponse{} + } + if opts.Range == "" { + opts.Range = RangeDay + } + if opts.View == "" { + opts.View = ViewBoth + } + if opts.Agent == "" { + opts.Agent = AgentAll + } + width := opts.Width + if width == 0 { + width = DefaultWidth + } + if width < minWidth { + width = minWidth + } + styles := newStaticStyles(opts.Color) + + var b strings.Builder + b.WriteString(renderControls(opts, styles)) + b.WriteString("\n\n") + b.WriteString(renderSummary(resp, opts, width, styles)) + b.WriteString("\n\n") + b.WriteString(renderActivity(resp, opts, width, styles)) + b.WriteString("\n\n") + b.WriteString(renderAgents(resp, opts, width, styles)) + b.WriteString("\n\n ") + b.WriteString(styles.info.Render("ℹ")) + b.WriteString(" ") + b.WriteString(styles.muted.Render(noteAnalysis)) + return b.String() +} + +func renderControls(opts RenderOptions, styles staticStyles) string { + ranges := []RangeKey{RangeDay, RangeWeek, RangeMonth, Range90d} + parts := make([]string, 0, len(ranges)) + for _, r := range ranges { + label := string(r) + if r == Range90d { + label = "90d" + } + if r == opts.Range { + label = styles.accent.Render("[" + label + "]") + } + parts = append(parts, label) + } + agent := opts.Agent + if agent == "" { + agent = AgentAll + } + return fmt.Sprintf("%s agent: [%s] view: %s", + strings.Join(parts, styles.muted.Render(" · ")), agent, renderViewSelector(opts.View, styles)) +} + +func renderViewSelector(view ViewMode, styles staticStyles) string { + labels := []struct { + mode ViewMode + label string + }{ + {ViewYou, "you"}, + {ViewTeam, "team"}, + {ViewBoth, "both"}, + } + var parts []string + for _, item := range labels { + if item.mode == view { + parts = append(parts, styles.accent.Render("["+item.label+"]")) + } else { + parts = append(parts, item.label) + } + } + return strings.Join(parts, " ") +} + +func renderSummary(resp *MeRecapResponse, opts RenderOptions, width int, styles staticStyles) string { + me := resp.Summary.Me + if me == (SummaryTotals{}) { + me = sumMe(resp, opts) + } + team := resp.Summary.Team + if team == nil { + if totals := sumTeam(resp, opts); totals != (SummaryTotals{}) { + team = &totals + } + } + top := topSignals(resp, opts, styles) + lines := []string{opts.Range.Title(), ""} + if opts.View != ViewTeam { + lines = append(lines, fmt.Sprintf("%s %-12s %-15s %s", + styles.accent.Render("you"), + plural(me.Sessions, "session"), plural(me.Checkpoints, "checkpoint"), formatTokens(me.Tokens)+" tok")) + } + if opts.View != ViewYou { + if team == nil { + lines = append(lines, styles.team.Render("team")+" - - -") + } else { + lines = append(lines, fmt.Sprintf("%s %-12s %-15s %s", + styles.team.Render("team"), + plural(team.Sessions, "session"), plural(team.Checkpoints, "checkpoint"), formatTokens(team.Tokens)+" tok")) + } + } + if len(top) > 0 { + lines = append(lines, "", styles.muted.Render("top")+" "+strings.Join(top, styles.muted.Render(" · "))) + } + context := []string{plural(len(filteredAgents(resp, opts)), "agent")} + if resp.Summary.RepoCount > 0 { + context = append(context, plural(resp.Summary.RepoCount, "repo")) + } + context = append(context, plural(resp.Summary.ActiveDays, "active day")) + lines = append(lines, "", styles.muted.Render(strings.Join(context, " · "))) + return renderBox("", lines, width, styles) +} + +func renderActivity(resp *MeRecapResponse, opts RenderOptions, width int, styles staticStyles) string { + mostDate, _ := mostActive(resp.Daily) + header := styles.title.Render("Activity") + styles.muted.Render(" · ") + rangeTag(opts.Range) + if mostDate != "" { + header = padRight(header, width-23) + styles.muted.Render("most active: ") + mostDate + } + lines := []string{header, renderActivityCells(resp.Daily, width, styles)} + return strings.Join(lines, "\n") +} + +func renderAgents(resp *MeRecapResponse, opts RenderOptions, width int, styles staticStyles) string { + agents := filteredAgents(resp, opts) + if len(agents) == 0 { + return renderBox("Agents · "+strings.ToLower(opts.Range.Title()), []string{styles.muted.Render("(no agent activity in range)")}, width, styles) + } + lines := []string{} + if opts.View == ViewBoth { + lines = append(lines, " "+styles.accent.Render("you ███")+" "+styles.team.Render("team ▒"), "") + } + for i, entry := range agents { + lines = append(lines, renderAgent(entry, opts, width-4, styles)...) + if i < len(agents)-1 { + lines = append(lines, "") + } + } + return renderBox("Agents · "+strings.ToLower(opts.Range.Title()), lines, width, styles) +} + +func renderAgent(entry AgentEntry, opts RenderOptions, width int, styles staticStyles) []string { + label := entry.AgentLabel + if label == "" { + label = entry.AgentID + } + lines := []string{styles.title.Render(label)} + metrics := []struct { + name string + me int + team int + }{ + {"tokens", entry.Me.Tokens, teamValue(entry.Contributors, func(a AgentAggregate) int { return a.Tokens })}, + {"sessions", entry.Me.Sessions, teamValue(entry.Contributors, func(a AgentAggregate) int { return a.Sessions })}, + {"checkpoints", entry.Me.Checkpoints, teamValue(entry.Contributors, func(a AgentAggregate) int { return a.Checkpoints })}, + } + for _, metric := range metrics { + if metric.me == 0 && metric.team == 0 { + continue + } + lines = append(lines, " "+ + padRight(styles.muted.Render(metric.name), 12)+" "+ + padRight(comparisonBar(metric.me, metric.team, opts.View, 32, styles), 32)+" "+ + styles.value.Render(metricReadout(metric.name, metric.me, metric.team, opts.View))) + } + if opts.View != ViewYou && entry.Contributors != nil { + lines = append(lines, qualitativeRows("team", *entry.Contributors, styles)...) + } + if opts.View != ViewTeam { + lines = append(lines, qualitativeRows("your", entry.Me, styles)...) + } + return fitLines(lines, width) +} + +func qualitativeRows(prefix string, agg AgentAggregate, styles staticStyles) []string { + var rows []string + if len(agg.Labels) > 0 { + rows = append(rows, " "+styles.muted.Render(prefix+" labels")+" "+formatLabels(agg.Labels, styles)) + } + if len(agg.Skills) > 0 { + rows = append(rows, " "+styles.muted.Render(prefix+" skills")+" "+formatSkills(agg.Skills, styles)) + } + if mix := formatToolMix(agg.ToolMix); mix != "" { + rows = append(rows, " "+styles.muted.Render(prefix+" tool mix")+" "+mix) + } + return rows +} + +func filteredAgents(resp *MeRecapResponse, opts RenderOptions) []AgentEntry { + agents := make([]AgentEntry, 0, len(resp.Agents)) + for key, entry := range resp.Agents { + if entry.AgentID == "" { + entry.AgentID = key + } + if opts.Agent != "" && opts.Agent != AgentAll && opts.Agent != entry.AgentID { + continue + } + agents = append(agents, entry) + } + sort.SliceStable(agents, func(i, j int) bool { + iScore := agents[i].Me.Sessions + agents[i].Me.Checkpoints + jScore := agents[j].Me.Sessions + agents[j].Me.Checkpoints + if iScore != jScore { + return iScore > jScore + } + return agents[i].AgentLabel < agents[j].AgentLabel + }) + return agents +} + +func sumMe(resp *MeRecapResponse, opts RenderOptions) SummaryTotals { + var out SummaryTotals + for _, entry := range filteredAgents(resp, opts) { + out.Sessions += entry.Me.Sessions + out.Checkpoints += entry.Me.Checkpoints + out.Tokens += entry.Me.Tokens + } + return out +} + +func sumTeam(resp *MeRecapResponse, opts RenderOptions) SummaryTotals { + var out SummaryTotals + for _, entry := range filteredAgents(resp, opts) { + if entry.Contributors == nil { + continue + } + out.Sessions += entry.Contributors.Sessions + out.Checkpoints += entry.Contributors.Checkpoints + out.Tokens += entry.Contributors.Tokens + } + return out +} + +func topSignals(resp *MeRecapResponse, opts RenderOptions, styles staticStyles) []string { + agents := filteredAgents(resp, opts) + var parts []string + if len(agents) > 0 { + label := agents[0].AgentLabel + if label == "" { + label = agents[0].AgentID + } + parts = append(parts, styles.accent.Render(label)) + } + if skill := topSkill(agents); skill != "" { + parts = append(parts, styles.skill.Render(skill)) + } + if label := topLabel(agents); label != "" { + parts = append(parts, labelStyle(label, styles).Render(label)) + } + return parts +} + +func topSkill(agents []AgentEntry) string { + counts := map[string]int{} + for _, agent := range agents { + for _, skill := range agent.Me.Skills { + counts[skill.Skill] += skill.Count + } + } + return topCount(counts) +} + +func topLabel(agents []AgentEntry) string { + counts := map[string]int{} + for _, agent := range agents { + for _, label := range agent.Me.Labels { + counts[label.Label] += label.Count + } + } + return topCount(counts) +} + +func topCount(counts map[string]int) string { + var keys []string + for key := range counts { + keys = append(keys, key) + } + sort.Strings(keys) + best := "" + bestN := 0 + for _, key := range keys { + if counts[key] > bestN { + best = key + bestN = counts[key] + } + } + return best +} + +func teamValue(agg *AgentAggregate, f func(AgentAggregate) int) int { + if agg == nil { + return 0 + } + return f(*agg) +} + +func metricReadout(metric string, me, team int, view ViewMode) string { + format := strconv.Itoa + if metric == "tokens" { + format = formatTokens + } + switch view { + case ViewYou: + return format(me) + case ViewTeam: + return format(team) + case ViewBoth: + if team == 0 { + return format(me) + " / -" + } + return format(me) + " / " + format(team) + default: + return format(me) + } +} + +func comparisonBar(me, team int, view ViewMode, width int, styles staticStyles) string { + switch view { + case ViewYou: + return strings.Repeat(styles.accent.Render("█"), scaledWidth(me, me, width)) + case ViewTeam: + return strings.Repeat(styles.team.Render("▒"), scaledWidth(team, team, width)) + case ViewBoth: + total := me + team + if total == 0 { + return "" + } + meWidth := scaledWidth(me, total, width) + teamWidth := scaledWidth(team, total, width) + return strings.Repeat(styles.accent.Render("█"), meWidth) + strings.Repeat(styles.team.Render("▒"), teamWidth) + default: + return "" + } +} + +func scaledWidth(value, total, width int) int { + if value <= 0 || total <= 0 || width <= 0 { + return 0 + } + n := int(math.Round(float64(value) * float64(width) / float64(total))) + if n == 0 { + return 1 + } + if n > width { + return width + } + return n +} + +func formatLabels(labels []LabelCount, styles staticStyles) string { + limit := min(len(labels), 3) + parts := make([]string, 0, limit) + for i := range limit { + parts = append(parts, labelStyle(labels[i].Label, styles).Render("● "+labels[i].Label)) + } + return strings.Join(parts, " ") +} + +func formatSkills(skills []SkillCount, styles staticStyles) string { + limit := min(len(skills), 3) + parts := make([]string, 0, limit) + for i := range limit { + parts = append(parts, styles.skill.Render(skills[i].Skill)) + } + return strings.Join(parts, ", ") +} + +func labelStyle(name string, styles staticStyles) interface{ Render(s ...string) string } { + switch name { + case "feature_build", "enhancement": + return styles.labelFeature + case "bug_fix", "security_fix": + return styles.labelFix + case "refactor", "optimization": + return styles.labelRefactor + case "testing": + return styles.labelTesting + case "configuration", "dependencies", "documentation", "investigation": + return styles.labelInfo + case "performance": + return styles.labelPerf + default: + return styles.value + } +} + +func formatToolMix(mix ToolMix) string { + values := []struct { + name string + count int + }{ + {"fileOps", mix.FileOps}, + {"search", mix.Search}, + {"shell", mix.Shell}, + {"mcp", mix.MCP}, + {"agent", mix.Agent}, + {"other", mix.Other}, + } + total := 0 + for _, value := range values { + total += value.count + } + if total == 0 { + return "" + } + sort.SliceStable(values, func(i, j int) bool { + if values[i].count != values[j].count { + return values[i].count > values[j].count + } + return values[i].name < values[j].name + }) + limit := min(len(values), 3) + parts := make([]string, 0, limit) + for i := range limit { + if values[i].count == 0 { + continue + } + pct := int(math.Round(float64(values[i].count) * 100 / float64(total))) + parts = append(parts, fmt.Sprintf("%s %d%%", values[i].name, pct)) + } + return strings.Join(parts, " · ") +} + +func renderActivityCells(daily []DailyCount, width int, styles staticStyles) string { + if len(daily) == 0 { + return "(no activity in range)" + } + inner := width - 2 + if inner < 1 { + inner = 1 + } + if len(daily) > inner { + daily = daily[len(daily)-inner:] + } + maxCount := 0 + for _, day := range daily { + if day.Count > maxCount { + maxCount = day.Count + } + } + var b strings.Builder + for _, day := range daily { + b.WriteString(activityCell(day.Count, maxCount, styles)) + } + return b.String() +} + +func activityCell(count, maxCount int, styles staticStyles) string { + r := activityRune(count, maxCount) + if count <= 0 || maxCount <= 0 { + return styles.activityEmpty.Render(string(r)) + } + ratio := float64(count) / float64(maxCount) + switch { + case ratio >= 0.75: + return styles.activityHigh.Render(string(r)) + case ratio >= 0.5: + return styles.activityMid.Render(string(r)) + default: + return styles.activityLow.Render(string(r)) + } +} + +func activityRune(count, maxCount int) rune { + if count <= 0 || maxCount <= 0 { + return '░' + } + ratio := float64(count) / float64(maxCount) + switch { + case ratio >= 0.75: + return '█' + case ratio >= 0.5: + return '▓' + case ratio >= 0.25: + return '▒' + default: + return '░' + } +} + +func mostActive(daily []DailyCount) (string, int) { + bestDate := "" + bestCount := 0 + for _, day := range daily { + if day.Count > bestCount { + bestDate = day.Date + bestCount = day.Count + } + } + return bestDate, bestCount +} + +func renderBox(title string, lines []string, width int, styles staticStyles) string { + inner := width - 2 + top := "╭" + strings.Repeat("─", inner) + "╮" + if title != "" { + titleText := "─ " + styles.title.Render(title) + " " + top = "╭" + titleText + strings.Repeat("─", max(inner-displayLen(titleText), 0)) + "╮" + } + out := []string{styles.border.Render(top)} + for _, line := range lines { + out = append(out, styles.border.Render("│")+padRight(" "+line, inner)+styles.border.Render("│")) + } + out = append(out, styles.border.Render("╰"+strings.Repeat("─", inner)+"╯")) + return strings.Join(out, "\n") +} + +func fitLines(lines []string, width int) []string { + out := make([]string, 0, len(lines)) + for _, line := range lines { + out = append(out, truncateRunes(line, width)) + } + return out +} + +func padRight(s string, width int) string { + if displayLen(s) >= width { + return s + } + return s + strings.Repeat(" ", width-displayLen(s)) +} + +func truncateRunes(s string, width int) string { + if displayLen(s) <= width { + return s + } + if width <= 1 { + return "" + } + return ansi.Truncate(s, width, "…") +} + +func displayLen(s string) int { + return ansi.StringWidth(s) +} + +func formatTokens(n int) string { + switch { + case n >= 1_000_000: + return fmt.Sprintf("%.1fM", float64(n)/1_000_000) + case n >= 1_000: + return fmt.Sprintf("%dk", n/1_000) + default: + return strconv.Itoa(n) + } +} + +func plural(n int, unit string) string { + if n == 1 { + return fmt.Sprintf("%d %s", n, unit) + } + return fmt.Sprintf("%d %ss", n, unit) +} + +func rangeTag(r RangeKey) string { + if r == Range90d { + return "90d" + } + return string(r) +} diff --git a/cmd/entire/cli/recap/static_server_test.go b/cmd/entire/cli/recap/static_server_test.go new file mode 100644 index 0000000000..b2b4e3f878 --- /dev/null +++ b/cmd/entire/cli/recap/static_server_test.go @@ -0,0 +1,163 @@ +package recap + +import ( + "strings" + "testing" +) + +func TestRenderStaticRecap_ServerBackedBoth90(t *testing.T) { + t.Parallel() + resp := &MeRecapResponse{ + Repo: ptr("entireio/cli"), + Summary: Summary{ + Me: SummaryTotals{Sessions: 40, Checkpoints: 92, Tokens: 3_500_000}, + Team: &SummaryTotals{Sessions: 5, Checkpoints: 6, Tokens: 17_000}, + RepoCount: 1, + ActiveDays: 14, + }, + Daily: []DailyCount{ + {Date: "2026-01-24", Count: 0}, + {Date: "2026-01-25", Count: 1}, + {Date: "2026-01-26", Count: 5}, + }, + Agents: map[string]AgentEntry{ + "claude": { + AgentID: "claude", + AgentLabel: "Claude Code", + Me: AgentAggregate{ + Sessions: 15, + Checkpoints: 92, + Tokens: 2_900_000, + Labels: []LabelCount{{Label: "bug_fix", Count: 2}}, + Skills: []SkillCount{{Skill: "code-simplifier", Count: 3}}, + ToolMix: ToolMix{FileOps: 61, Search: 18, Shell: 15}, + }, + Contributors: &AgentAggregate{ + Sessions: 2, + Checkpoints: 2, + Tokens: 1_000, + Labels: []LabelCount{{Label: "refactor", Count: 1}}, + Skills: []SkillCount{{Skill: "session-handoff", Count: 1}}, + ToolMix: ToolMix{FileOps: 6, Search: 2, Shell: 1}, + }, + }, + "codex": { + AgentID: "codex", + AgentLabel: "Codex", + Me: AgentAggregate{ + Sessions: 24, + Tokens: 647_000, + Skills: []SkillCount{{Skill: "codex:codex-rescue", Count: 1}}, + }, + }, + }, + } + + got := RenderStaticRecap(resp, RenderOptions{ + Range: Range90d, + View: ViewBoth, + Agent: "all", + Width: 78, + }) + + for _, want := range []string{ + "day · week · month · [90d]", + "agent: [all]", + "view: you team [both]", + "Last 90 days", + "you 40 sessions 92 checkpoints 3.5M tok", + "team 5 sessions 6 checkpoints 17k tok", + "1 repo · 14 active days", + "Activity · 90d", + "Agents · last 90 days", + "Claude Code", + "tokens", + "2.9M / 1k", + "team labels", + "your skills", + "Labels require server analysis", + } { + if !strings.Contains(got, want) { + t.Fatalf("output missing %q:\n%s", want, got) + } + } +} + +func TestRenderStaticRecap_TeamViewOmitsYouSummary(t *testing.T) { + t.Parallel() + resp := &MeRecapResponse{ + Summary: Summary{ + Me: SummaryTotals{Sessions: 2, Checkpoints: 3, Tokens: 100}, + Team: &SummaryTotals{Sessions: 4, Checkpoints: 5, Tokens: 200}, + }, + } + got := RenderStaticRecap(resp, RenderOptions{Range: RangeWeek, View: ViewTeam, Agent: AgentAll, Width: 78}) + if strings.Contains(got, "you ") { + t.Fatalf("team view should omit you summary:\n%s", got) + } + if !strings.Contains(got, "team 4 sessions") { + t.Fatalf("team view should include team summary:\n%s", got) + } +} + +func TestRenderStaticRecap_ColorWhenEnabled(t *testing.T) { + t.Parallel() + resp := &MeRecapResponse{ + Summary: Summary{Me: SummaryTotals{Sessions: 1, Checkpoints: 2, Tokens: 300}}, + Daily: []DailyCount{ + {Date: "2026-01-24", Count: 0}, + {Date: "2026-01-25", Count: 1}, + {Date: "2026-01-26", Count: 4}, + }, + Agents: map[string]AgentEntry{ + "codex": { + AgentID: "codex", + AgentLabel: "Codex", + Me: AgentAggregate{ + Sessions: 1, + Checkpoints: 2, + Tokens: 300, + Labels: []LabelCount{{Label: "bug_fix", Count: 1}}, + Skills: []SkillCount{{Skill: "code-simplifier", Count: 1}}, + }, + }, + }, + } + + colored := RenderStaticRecap(resp, RenderOptions{ + Range: Range90d, + View: ViewBoth, + Agent: AgentAll, + Width: 78, + Color: true, + }) + if !strings.Contains(colored, "\x1b[") { + t.Fatalf("expected ANSI styling when color is enabled:\n%s", colored) + } + if !strings.Contains(colored, "\x1b[38;5;240m░") { + t.Fatalf("expected empty activity cells to be muted:\n%s", colored) + } + if !strings.Contains(colored, "\x1b[1;38;5;214m█") { + t.Fatalf("expected peak activity cells to be highlighted:\n%s", colored) + } + if !strings.Contains(colored, "\x1b[38;5;203m● bug_fix") { + t.Fatalf("expected labels to use semantic colors:\n%s", colored) + } + if !strings.Contains(colored, "\x1b[36mcode-simplifier") { + t.Fatalf("expected skills to be colorized:\n%s", colored) + } + + plain := RenderStaticRecap(resp, RenderOptions{ + Range: Range90d, + View: ViewBoth, + Agent: AgentAll, + Width: 78, + }) + if strings.Contains(plain, "\x1b[") { + t.Fatalf("plain output should not contain ANSI styling:\n%s", plain) + } +} + +func ptr(s string) *string { + return &s +} diff --git a/cmd/entire/cli/recap/styles.go b/cmd/entire/cli/recap/styles.go new file mode 100644 index 0000000000..301414e33c --- /dev/null +++ b/cmd/entire/cli/recap/styles.go @@ -0,0 +1,69 @@ +package recap + +import "charm.land/lipgloss/v2" + +const ( + colorAccent = "214" + colorMuted = "8" + colorBorder = "243" + colorInfo = "6" + colorTeam = "170" + + colorActivityEmpty = "240" + colorActivityLow = "6" + colorActivityMid = "214" + + colorLabelFeature = "42" + colorLabelFix = "203" + colorLabelInformation = "81" + colorLabelPerformance = "214" + colorLabelRefactor = "220" + colorLabelTesting = "170" +) + +type staticStyles struct { + accent lipgloss.Style + activityEmpty lipgloss.Style + activityHigh lipgloss.Style + activityLow lipgloss.Style + activityMid lipgloss.Style + border lipgloss.Style + info lipgloss.Style + labelFeature lipgloss.Style + labelFix lipgloss.Style + labelInfo lipgloss.Style + labelPerf lipgloss.Style + labelRefactor lipgloss.Style + labelTesting lipgloss.Style + muted lipgloss.Style + skill lipgloss.Style + team lipgloss.Style + title lipgloss.Style + value lipgloss.Style +} + +func newStaticStyles(useColor bool) staticStyles { + if !useColor { + return staticStyles{} + } + return staticStyles{ + accent: lipgloss.NewStyle().Foreground(lipgloss.Color(colorAccent)), + activityEmpty: lipgloss.NewStyle().Foreground(lipgloss.Color(colorActivityEmpty)), + activityHigh: lipgloss.NewStyle().Foreground(lipgloss.Color(colorActivityMid)).Bold(true), + activityLow: lipgloss.NewStyle().Foreground(lipgloss.Color(colorActivityLow)), + activityMid: lipgloss.NewStyle().Foreground(lipgloss.Color(colorActivityMid)), + border: lipgloss.NewStyle().Foreground(lipgloss.Color(colorBorder)), + info: lipgloss.NewStyle().Foreground(lipgloss.Color(colorInfo)), + labelFeature: lipgloss.NewStyle().Foreground(lipgloss.Color(colorLabelFeature)), + labelFix: lipgloss.NewStyle().Foreground(lipgloss.Color(colorLabelFix)), + labelInfo: lipgloss.NewStyle().Foreground(lipgloss.Color(colorLabelInformation)), + labelPerf: lipgloss.NewStyle().Foreground(lipgloss.Color(colorLabelPerformance)), + labelRefactor: lipgloss.NewStyle().Foreground(lipgloss.Color(colorLabelRefactor)), + labelTesting: lipgloss.NewStyle().Foreground(lipgloss.Color(colorLabelTesting)), + muted: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)), + skill: lipgloss.NewStyle().Foreground(lipgloss.Color(colorInfo)), + team: lipgloss.NewStyle().Foreground(lipgloss.Color(colorTeam)).Bold(true), + title: lipgloss.NewStyle().Foreground(lipgloss.Color(colorAccent)).Bold(true), + value: lipgloss.NewStyle().Bold(true), + } +} diff --git a/cmd/entire/cli/recap_test.go b/cmd/entire/cli/recap_test.go new file mode 100644 index 0000000000..f7769294fb --- /dev/null +++ b/cmd/entire/cli/recap_test.go @@ -0,0 +1,109 @@ +package cli + +import ( + "bytes" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/recap" +) + +const recapTestAgentCodex = "codex" + +func TestRecapFlags_RangeKey(t *testing.T) { + t.Parallel() + cases := []struct { + name string + flags recapFlags + want recap.RangeKey + }{ + {"default_day", recapFlags{}, recap.RangeDay}, + {"day", recapFlags{day: true}, recap.RangeDay}, + {"week", recapFlags{week: true}, recap.RangeWeek}, + {"month", recapFlags{month: true}, recap.RangeMonth}, + {"90d", recapFlags{d90: true}, recap.Range90d}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + t.Parallel() + if got := c.flags.rangeKey(); got != c.want { + t.Errorf("rangeKey() = %q, want %q", got, c.want) + } + }) + } +} + +func TestRecapFlags_Mode(t *testing.T) { + t.Parallel() + cases := []struct { + view string + want recap.ViewMode + }{ + {"", recap.ViewBoth}, + {"both", recap.ViewBoth}, + {"you", recap.ViewYou}, + {"me", recap.ViewYou}, + {"team", recap.ViewTeam}, + {"contributors", recap.ViewTeam}, + } + for _, c := range cases { + t.Run(c.view, func(t *testing.T) { + t.Parallel() + if got := (&recapFlags{view: c.view}).mode(); got != c.want { + t.Errorf("mode() = %q, want %q", got, c.want) + } + }) + } +} + +func TestRecapCmd_RegistersStaticFlags(t *testing.T) { + t.Parallel() + cmd := newRecapCmd() + for _, name := range []string{"day", "week", "month", "90", "agent", "view", "color", "insecure-http-auth"} { + if flag := cmd.Flag(name); flag == nil { + t.Errorf("flag --%s not registered", name) + } + } +} + +func TestRecapFlags_AgentName(t *testing.T) { + t.Parallel() + if got := (&recapFlags{}).agentName(); got != recap.AgentAll { + t.Errorf("default agent = %q, want all", got) + } + if got := (&recapFlags{agent: " Codex "}).agentName(); got != recapTestAgentCodex { + t.Errorf("agent = %q, want %s", got, recapTestAgentCodex) + } +} + +func TestRecapFlags_ColorEnabled(t *testing.T) { + t.Parallel() + var out bytes.Buffer + + got, err := (&recapFlags{color: "always"}).colorEnabled(&out) + if err != nil { + t.Fatalf("colorEnabled(always) error = %v", err) + } + if !got { + t.Fatal("colorEnabled(always) = false, want true") + } + + got, err = (&recapFlags{color: "never"}).colorEnabled(&out) + if err != nil { + t.Fatalf("colorEnabled(never) error = %v", err) + } + if got { + t.Fatal("colorEnabled(never) = true, want false") + } + + got, err = (&recapFlags{color: "auto"}).colorEnabled(&out) + if err != nil { + t.Fatalf("colorEnabled(auto) error = %v", err) + } + if got { + t.Fatal("colorEnabled(auto non-tty) = true, want false") + } + + if _, err := (&recapFlags{color: "rainbow"}).colorEnabled(&out); err == nil { + t.Fatal("colorEnabled(invalid) error = nil, want error") + } +} diff --git a/cmd/entire/cli/root.go b/cmd/entire/cli/root.go index 18143e5e31..0606c2ca79 100644 --- a/cmd/entire/cli/root.go +++ b/cmd/entire/cli/root.go @@ -97,6 +97,7 @@ func NewRootCmd() *cobra.Command { cmd.AddCommand(newVersionCmd()) cmd.AddCommand(newDispatchCmd()) cmd.AddCommand(newActivityCmd()) + cmd.AddCommand(newRecapCmd()) // Hidden top-level shortcuts. Functional but print a deprecation hint. cmd.AddCommand(hideAsAlias(newRewindCmd(), "entire checkpoint rewind")) diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index 4e849fdc5b..2abd84c427 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -411,26 +411,36 @@ func (s *StateStore) Save(ctx context.Context, state *State) error { return fmt.Errorf("failed to marshal session state: %w", err) } - // Use os.Root for traversal-resistant write of temp file. - // Rename is not available on os.Root, so we keep using os.Rename. - root, err := os.OpenRoot(s.stateDir) + stateFile := s.stateFilePath(state.SessionID) + fileName := state.SessionID + ".json" + + // Use a unique temp file per save. Concurrent hook processes can write the + // same session ID, so a fixed ".json.tmp" path can corrupt JSON. + tmpFile, err := os.CreateTemp(s.stateDir, fileName+".*.tmp") if err != nil { - return fmt.Errorf("failed to open session state directory: %w", err) + return fmt.Errorf("failed to create temporary session state file: %w", err) } - defer root.Close() + tmpFileName := tmpFile.Name() + removeTmp := true + defer func() { + if removeTmp { + _ = os.Remove(tmpFileName) + } + }() - fileName := state.SessionID + ".json" - tmpFileName := fileName + ".tmp" - if err := osroot.WriteFile(root, tmpFileName, data, 0o600); err != nil { + if _, err := tmpFile.Write(data); err != nil { + _ = tmpFile.Close() return fmt.Errorf("failed to write session state: %w", err) } + if err := tmpFile.Close(); err != nil { + return fmt.Errorf("failed to close session state file: %w", err) + } - // Atomic rename: not available on os.Root, use os.Rename with validated paths. - stateFile := s.stateFilePath(state.SessionID) - tmpFile := stateFile + ".tmp" - if err := os.Rename(tmpFile, stateFile); err != nil { + // Atomic rename into the validated final path. + if err := os.Rename(tmpFileName, stateFile); err != nil { return fmt.Errorf("failed to rename session state file: %w", err) } + removeTmp = false return nil } diff --git a/cmd/entire/cli/strategy/session_state.go b/cmd/entire/cli/strategy/session_state.go index b160dc3875..9c565a48dd 100644 --- a/cmd/entire/cli/strategy/session_state.go +++ b/cmd/entire/cli/strategy/session_state.go @@ -11,7 +11,6 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/types" - "github.com/entireio/cli/cmd/entire/cli/jsonutil" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/session" @@ -58,37 +57,13 @@ func LoadSessionState(ctx context.Context, sessionID string) (*SessionState, err // SaveSessionState saves the session state atomically. func SaveSessionState(ctx context.Context, state *SessionState) error { - // Validate session ID to prevent path traversal - if err := validation.ValidateSessionID(state.SessionID); err != nil { - return fmt.Errorf("invalid session ID: %w", err) - } - - stateDir, err := getSessionStateDir(ctx) - if err != nil { - return fmt.Errorf("failed to get session state directory: %w", err) - } - - if err := os.MkdirAll(stateDir, 0o750); err != nil { - return fmt.Errorf("failed to create session state directory: %w", err) - } - - data, err := jsonutil.MarshalIndentWithNewline(state, "", " ") - if err != nil { - return fmt.Errorf("failed to marshal session state: %w", err) - } - - stateFile, err := sessionStateFile(ctx, state.SessionID) + store, err := session.NewStateStore(ctx) if err != nil { - return fmt.Errorf("failed to get session state file path: %w", err) + return fmt.Errorf("failed to create state store: %w", err) } - // Atomic write: write to temp file, then rename - tmpFile := stateFile + ".tmp" - if err := os.WriteFile(tmpFile, data, 0o600); err != nil { - return fmt.Errorf("failed to write session state: %w", err) - } - if err := os.Rename(tmpFile, stateFile); err != nil { - return fmt.Errorf("failed to rename session state file: %w", err) + if err := store.Save(ctx, state); err != nil { + return fmt.Errorf("failed to save session state: %w", err) } return nil } diff --git a/go.mod b/go.mod index 5ed9b06d6c..79dd6642f8 100644 --- a/go.mod +++ b/go.mod @@ -9,12 +9,14 @@ require ( charm.land/huh/v2 v2.0.3 charm.land/lipgloss/v2 v2.0.3 github.com/betterleaks/betterleaks v1.1.2 + github.com/charmbracelet/x/ansi v0.11.7 github.com/creack/pty v1.1.24 github.com/denisbrodbeck/machineid v1.0.1 github.com/go-git/go-billy/v6 v6.0.0-20260328065524-593ae452e14d github.com/go-git/go-git/v6 v6.0.0-alpha.2 github.com/go-git/x/plugin/objectsigner/auto v0.0.0-20260330134459-33df49246da9 github.com/google/uuid v1.6.0 + github.com/mattn/go-isatty v0.0.20 github.com/muesli/termenv v0.16.0 github.com/posthog/posthog-go v1.12.4 github.com/sergi/go-diff v1.4.0 @@ -49,7 +51,6 @@ require ( github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect @@ -90,7 +91,6 @@ require ( github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/magiconair/properties v1.8.9 // indirect github.com/mattn/go-colorable v0.1.14 // indirect - github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect github.com/mholt/archives v0.1.5 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect