From d20405606e3281aea4c568ed732e847037ffb570 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 8 Apr 2026 15:35:03 -0700 Subject: [PATCH 1/6] Add explain summary provider selection --- cmd/entire/cli/agent/codex/generate.go | 55 +++++ cmd/entire/cli/agent/copilotcli/generate.go | 53 +++++ cmd/entire/cli/agent/cursor/generate.go | 53 +++++ cmd/entire/cli/agent/geminicli/generate.go | 54 +++++ cmd/entire/cli/explain.go | 8 +- cmd/entire/cli/explain_summary_provider.go | 199 ++++++++++++++++++ .../cli/explain_summary_provider_test.go | 145 +++++++++++++ cmd/entire/cli/settings/settings.go | 44 ++++ cmd/entire/cli/settings/settings_test.go | 85 ++++++++ cmd/entire/cli/summarize/claude.go | 34 +-- cmd/entire/cli/summarize/text_generator.go | 30 +++ .../cli/summarize/text_generator_test.go | 74 +++++++ 12 files changed, 821 insertions(+), 13 deletions(-) create mode 100644 cmd/entire/cli/agent/codex/generate.go create mode 100644 cmd/entire/cli/agent/copilotcli/generate.go create mode 100644 cmd/entire/cli/agent/cursor/generate.go create mode 100644 cmd/entire/cli/agent/geminicli/generate.go create mode 100644 cmd/entire/cli/explain_summary_provider.go create mode 100644 cmd/entire/cli/explain_summary_provider_test.go create mode 100644 cmd/entire/cli/summarize/text_generator.go create mode 100644 cmd/entire/cli/summarize/text_generator_test.go diff --git a/cmd/entire/cli/agent/codex/generate.go b/cmd/entire/cli/agent/codex/generate.go new file mode 100644 index 000000000..364725be6 --- /dev/null +++ b/cmd/entire/cli/agent/codex/generate.go @@ -0,0 +1,55 @@ +package codex + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +var codexCommandRunner = exec.CommandContext + +// GenerateText sends a prompt to the Codex CLI and returns the raw text response. +func (c *CodexAgent) GenerateText(ctx context.Context, prompt string, model string) (string, error) { + args := []string{"exec"} + if model != "" { + args = append(args, "--model", model) + } + args = append(args, "-") + + cmd := codexCommandRunner(ctx, "codex", args...) + cmd.Dir = os.TempDir() + cmd.Env = stripGitEnv(os.Environ()) + cmd.Stdin = strings.NewReader(prompt) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + var execErr *exec.Error + if errors.As(err, &execErr) { + return "", fmt.Errorf("codex CLI not found: %w", err) + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return "", fmt.Errorf("codex CLI failed (exit %d): %s", exitErr.ExitCode(), stderr.String()) + } + return "", fmt.Errorf("failed to run codex CLI: %w", err) + } + + return strings.TrimSpace(stdout.String()), nil +} + +func stripGitEnv(env []string) []string { + filtered := make([]string, 0, len(env)) + for _, e := range env { + if !strings.HasPrefix(e, "GIT_") { + filtered = append(filtered, e) + } + } + return filtered +} diff --git a/cmd/entire/cli/agent/copilotcli/generate.go b/cmd/entire/cli/agent/copilotcli/generate.go new file mode 100644 index 000000000..33e223441 --- /dev/null +++ b/cmd/entire/cli/agent/copilotcli/generate.go @@ -0,0 +1,53 @@ +package copilotcli + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +var copilotCommandRunner = exec.CommandContext + +// GenerateText sends a prompt to the Copilot CLI and returns the raw text response. +func (c *CopilotCLIAgent) GenerateText(ctx context.Context, prompt string, model string) (string, error) { + args := []string{"-p", prompt, "--allow-all-tools"} + if model != "" { + args = append(args, "--model", model) + } + + cmd := copilotCommandRunner(ctx, "copilot", args...) + cmd.Dir = os.TempDir() + cmd.Env = stripGitEnv(os.Environ()) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + var execErr *exec.Error + if errors.As(err, &execErr) { + return "", fmt.Errorf("copilot CLI not found: %w", err) + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return "", fmt.Errorf("copilot CLI failed (exit %d): %s", exitErr.ExitCode(), stderr.String()) + } + return "", fmt.Errorf("failed to run copilot CLI: %w", err) + } + + return strings.TrimSpace(stdout.String()), nil +} + +func stripGitEnv(env []string) []string { + filtered := make([]string, 0, len(env)) + for _, e := range env { + if !strings.HasPrefix(e, "GIT_") { + filtered = append(filtered, e) + } + } + return filtered +} diff --git a/cmd/entire/cli/agent/cursor/generate.go b/cmd/entire/cli/agent/cursor/generate.go new file mode 100644 index 000000000..79105fc07 --- /dev/null +++ b/cmd/entire/cli/agent/cursor/generate.go @@ -0,0 +1,53 @@ +package cursor + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +var cursorCommandRunner = exec.CommandContext + +// GenerateText sends a prompt to the Cursor agent CLI and returns the raw text response. +func (c *CursorAgent) GenerateText(ctx context.Context, prompt string, model string) (string, error) { + args := []string{"-p", prompt, "--force", "--trust", "--workspace", os.TempDir()} + if model != "" { + args = append(args, "--model", model) + } + + cmd := cursorCommandRunner(ctx, "agent", args...) + cmd.Dir = os.TempDir() + cmd.Env = stripGitEnv(os.Environ()) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + var execErr *exec.Error + if errors.As(err, &execErr) { + return "", fmt.Errorf("cursor CLI not found: %w", err) + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return "", fmt.Errorf("cursor CLI failed (exit %d): %s", exitErr.ExitCode(), stderr.String()) + } + return "", fmt.Errorf("failed to run cursor CLI: %w", err) + } + + return strings.TrimSpace(stdout.String()), nil +} + +func stripGitEnv(env []string) []string { + filtered := make([]string, 0, len(env)) + for _, e := range env { + if !strings.HasPrefix(e, "GIT_") { + filtered = append(filtered, e) + } + } + return filtered +} diff --git a/cmd/entire/cli/agent/geminicli/generate.go b/cmd/entire/cli/agent/geminicli/generate.go new file mode 100644 index 000000000..40e8f694e --- /dev/null +++ b/cmd/entire/cli/agent/geminicli/generate.go @@ -0,0 +1,54 @@ +package geminicli + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +var geminiCommandRunner = exec.CommandContext + +// GenerateText sends a prompt to the Gemini CLI and returns the raw text response. +func (g *GeminiCLIAgent) GenerateText(ctx context.Context, prompt string, model string) (string, error) { + args := []string{"-p", ""} + if model != "" { + args = append(args, "--model", model) + } + + cmd := geminiCommandRunner(ctx, "gemini", args...) + cmd.Dir = os.TempDir() + cmd.Env = stripGitEnv(os.Environ()) + cmd.Stdin = strings.NewReader(prompt) + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + var execErr *exec.Error + if errors.As(err, &execErr) { + return "", fmt.Errorf("gemini CLI not found: %w", err) + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return "", fmt.Errorf("gemini CLI failed (exit %d): %s", exitErr.ExitCode(), stderr.String()) + } + return "", fmt.Errorf("failed to run gemini CLI: %w", err) + } + + return strings.TrimSpace(stdout.String()), nil +} + +func stripGitEnv(env []string) []string { + filtered := make([]string, 0, len(env)) + for _, e := range env { + if !strings.HasPrefix(e, "GIT_") { + filtered = append(filtered, e) + } + } + return filtered +} diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index 4bbb5f034..105937ddb 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -339,10 +339,15 @@ func generateCheckpointSummary(ctx context.Context, w, _ io.Writer, store *check return fmt.Errorf("checkpoint %s has no transcript content for this checkpoint (scoped)", checkpointID) } + provider, err := resolveCheckpointSummaryProvider(ctx, w) + if err != nil { + return fmt.Errorf("failed to resolve summary provider: %w", err) + } + // Generate summary using shared helper logging.Info(ctx, "generating checkpoint summary") - summary, err := summarize.GenerateFromTranscript(ctx, scopedTranscript, cpSummary.FilesTouched, content.Metadata.Agent, nil) + summary, err := summarize.GenerateFromTranscript(ctx, scopedTranscript, cpSummary.FilesTouched, content.Metadata.Agent, provider.Generator) if err != nil { return fmt.Errorf("failed to generate summary: %w", err) } @@ -353,6 +358,7 @@ func generateCheckpointSummary(ctx context.Context, w, _ io.Writer, store *check } fmt.Fprintln(w, "✓ Summary generated and saved") + fmt.Fprint(w, formatSummaryProviderDetails(provider)) return nil } diff --git a/cmd/entire/cli/explain_summary_provider.go b/cmd/entire/cli/explain_summary_provider.go new file mode 100644 index 000000000..3ba8d0c3c --- /dev/null +++ b/cmd/entire/cli/explain_summary_provider.go @@ -0,0 +1,199 @@ +package cli + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/paths" + "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/entireio/cli/cmd/entire/cli/summarize" + + "github.com/charmbracelet/huh" +) + +var ( + loadSummarySettings = LoadEntireSettings + loadSummarySettingsFromFile = settings.LoadFromFile + saveProjectSummarySettings = SaveEntireSettings + saveLocalSummarySettings = SaveEntireSettingsLocal + getSummaryAgent = agent.Get + listInstalledAgents = GetAgentsWithHooksInstalled +) + +type checkpointSummaryProvider struct { + Name types.AgentName + DisplayName string + Model string + DisplayModel string + Generator summarize.Generator +} + +func resolveCheckpointSummaryProvider(ctx context.Context, w io.Writer) (*checkpointSummaryProvider, error) { + s, err := loadSummarySettings(ctx) + if err != nil { + return nil, fmt.Errorf("loading settings: %w", err) + } + + if s.SummaryGeneration != nil && s.SummaryGeneration.Provider != "" { + return buildCheckpointSummaryProvider(types.AgentName(s.SummaryGeneration.Provider), s.SummaryGeneration.Model) + } + + candidates, err := listEnabledSummaryProviders() + if err != nil { + return nil, err + } + + switch len(candidates) { + case 0: + return buildCheckpointSummaryProvider(agent.AgentNameClaudeCode, "") + case 1: + provider, err := buildCheckpointSummaryProvider(candidates[0].Name, "") + if err != nil { + return nil, err + } + if saveErr := persistSummaryProviderSelection(ctx, provider.Name, provider.Model); saveErr != nil { + return nil, saveErr + } + return provider, nil + default: + if !canPromptInteractively() { + return buildCheckpointSummaryProvider(agent.AgentNameClaudeCode, "") + } + + selected, err := promptForSummaryProvider(candidates) + if err != nil { + return nil, err + } + + provider, err := buildCheckpointSummaryProvider(selected, "") + if err != nil { + return nil, err + } + if saveErr := persistSummaryProviderSelection(ctx, provider.Name, provider.Model); saveErr != nil { + return nil, saveErr + } + fmt.Fprintf(w, "Using %s for summary generation.\n", provider.DisplayName) + return provider, nil + } +} + +func listEnabledSummaryProviders() ([]checkpointSummaryProvider, error) { + installed := listInstalledAgents(context.Background()) + providers := make([]checkpointSummaryProvider, 0, len(installed)) + for _, name := range installed { + ag, err := getSummaryAgent(name) + if err != nil { + return nil, fmt.Errorf("loading agent %s: %w", name, err) + } + if _, ok := agent.AsTextGenerator(ag); !ok { + continue + } + providers = append(providers, checkpointSummaryProvider{ + Name: name, + DisplayName: string(ag.Type()), + }) + } + return providers, nil +} + +func promptForSummaryProvider(providers []checkpointSummaryProvider) (types.AgentName, error) { + options := make([]huh.Option[string], 0, len(providers)) + for _, provider := range providers { + options = append(options, huh.NewOption(provider.DisplayName, string(provider.Name))) + } + + var selected string + form := NewAccessibleForm( + huh.NewGroup( + huh.NewSelect[string](). + Title("Choose a summary provider"). + Description("This choice will be saved. Use `entire configure` to change it later."). + Options(options...). + Value(&selected), + ), + ) + if err := form.Run(); err != nil { + return "", fmt.Errorf("summary provider selection cancelled: %w", err) + } + + return types.AgentName(selected), nil +} + +func buildCheckpointSummaryProvider(name types.AgentName, model string) (*checkpointSummaryProvider, error) { + ag, err := getSummaryAgent(name) + if err != nil { + return nil, fmt.Errorf("loading summary provider %s: %w", name, err) + } + + textGenerator, ok := agent.AsTextGenerator(ag) + if !ok { + return nil, fmt.Errorf("agent %s does not support summary generation", name) + } + + effectiveModel := model + displayModel := model + if name == agent.AgentNameClaudeCode && effectiveModel == "" { + effectiveModel = summarize.DefaultModel + displayModel = summarize.DefaultModel + } + if displayModel == "" { + displayModel = "provider default" + } + + return &checkpointSummaryProvider{ + Name: name, + DisplayName: string(ag.Type()), + Model: effectiveModel, + DisplayModel: displayModel, + Generator: &summarize.TextGeneratorAdapter{ + TextGenerator: textGenerator, + Model: effectiveModel, + }, + }, nil +} + +func persistSummaryProviderSelection(ctx context.Context, provider types.AgentName, model string) error { + targetFile, _ := settingsTargetFile(ctx, false, false) + targetFileAbs, err := paths.AbsPath(ctx, targetFile) + if err != nil { + targetFileAbs = targetFile + } + + s, err := loadSummarySettingsFromFile(targetFileAbs) + if err != nil { + return fmt.Errorf("loading settings for update: %w", err) + } + if s.SummaryGeneration == nil { + s.SummaryGeneration = &settings.SummaryGenerationSettings{} + } + s.SummaryGeneration.Provider = string(provider) + if model != "" { + s.SummaryGeneration.Model = model + } + + if targetFile == settings.EntireSettingsLocalFile { + if err := saveLocalSummarySettings(ctx, s); err != nil { + return fmt.Errorf("saving summary provider selection: %w", err) + } + return nil + } + if err := saveProjectSummarySettings(ctx, s); err != nil { + return fmt.Errorf("saving summary provider selection: %w", err) + } + return nil +} + +func formatSummaryProviderDetails(provider *checkpointSummaryProvider) string { + if provider == nil { + return "" + } + + var b strings.Builder + fmt.Fprintf(&b, "Provider: %s\n", provider.DisplayName) + fmt.Fprintf(&b, "Model: %s\n", provider.DisplayModel) + return b.String() +} diff --git a/cmd/entire/cli/explain_summary_provider_test.go b/cmd/entire/cli/explain_summary_provider_test.go new file mode 100644 index 000000000..7e557f030 --- /dev/null +++ b/cmd/entire/cli/explain_summary_provider_test.go @@ -0,0 +1,145 @@ +package cli + +import ( + "bytes" + "context" + "path/filepath" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/entireio/cli/cmd/entire/cli/testutil" +) + +type stubTextAgent struct { + name types.AgentName + kind types.AgentType +} + +func (s *stubTextAgent) Name() types.AgentName { return s.name } +func (s *stubTextAgent) Type() types.AgentType { return s.kind } +func (s *stubTextAgent) Description() string { return "stub" } +func (s *stubTextAgent) IsPreview() bool { return false } +func (s *stubTextAgent) DetectPresence(context.Context) (bool, error) { return true, nil } +func (s *stubTextAgent) ProtectedDirs() []string { return nil } +func (s *stubTextAgent) ReadTranscript(string) ([]byte, error) { return nil, nil } +func (s *stubTextAgent) ChunkTranscript(context.Context, []byte, int) ([][]byte, error) { + return nil, nil +} +func (s *stubTextAgent) ReassembleTranscript([][]byte) ([]byte, error) { return nil, nil } +func (s *stubTextAgent) GetSessionID(*agent.HookInput) string { return "" } +func (s *stubTextAgent) GetSessionDir(string) (string, error) { return "", nil } +func (s *stubTextAgent) ResolveSessionFile(string, string) string { return "" } +func (s *stubTextAgent) ReadSession(*agent.HookInput) (*agent.AgentSession, error) { + return nil, nil //nolint:nilnil // test stub +} +func (s *stubTextAgent) WriteSession(context.Context, *agent.AgentSession) error { return nil } +func (s *stubTextAgent) FormatResumeCommand(string) string { return "" } +func (s *stubTextAgent) GenerateText(context.Context, string, string) (string, error) { + return `{"intent":"Intent","outcome":"Outcome","learnings":{"repo":[],"code":[],"workflow":[]},"friction":[],"open_items":[]}`, nil +} + +func TestResolveCheckpointSummaryProvider_UsesConfiguredProvider(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + t.Chdir(tmpDir) + + originalLoad := loadSummarySettings + originalGet := getSummaryAgent + t.Cleanup(func() { + loadSummarySettings = originalLoad + getSummaryAgent = originalGet + }) + + loadSummarySettings = func(context.Context) (*settings.EntireSettings, error) { + return &settings.EntireSettings{ + Enabled: true, + SummaryGeneration: &settings.SummaryGenerationSettings{ + Provider: string(agent.AgentNameClaudeCode), + Model: "haiku", + }, + }, nil + } + getSummaryAgent = func(name types.AgentName) (agent.Agent, error) { + return &stubTextAgent{ + name: name, + kind: agent.AgentTypeClaudeCode, + }, nil + } + + provider, err := resolveCheckpointSummaryProvider(ctx, &bytes.Buffer{}) + if err != nil { + t.Fatalf("resolveCheckpointSummaryProvider() error = %v", err) + } + + if provider.Name != agent.AgentNameClaudeCode { + t.Fatalf("provider.Name = %q, want %q", provider.Name, agent.AgentNameClaudeCode) + } + if provider.DisplayModel != "haiku" { + t.Fatalf("provider.DisplayModel = %q, want %q", provider.DisplayModel, "haiku") + } +} + +func TestResolveCheckpointSummaryProvider_SavesSingleInstalledProvider(t *testing.T) { + ctx := context.Background() + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + t.Chdir(tmpDir) + + originalLoad := loadSummarySettings + originalGet := getSummaryAgent + originalList := listInstalledAgents + t.Cleanup(func() { + loadSummarySettings = originalLoad + getSummaryAgent = originalGet + listInstalledAgents = originalList + }) + + loadSummarySettings = func(context.Context) (*settings.EntireSettings, error) { + return &settings.EntireSettings{Enabled: true}, nil + } + listInstalledAgents = func(context.Context) []types.AgentName { + return []types.AgentName{agent.AgentNameCodex} + } + getSummaryAgent = func(name types.AgentName) (agent.Agent, error) { + return &stubTextAgent{ + name: name, + kind: agent.AgentTypeCodex, + }, nil + } + + provider, err := resolveCheckpointSummaryProvider(ctx, &bytes.Buffer{}) + if err != nil { + t.Fatalf("resolveCheckpointSummaryProvider() error = %v", err) + } + if provider.Name != agent.AgentNameCodex { + t.Fatalf("provider.Name = %q, want %q", provider.Name, agent.AgentNameCodex) + } + + settingsPath := filepath.Join(tmpDir, ".entire", "settings.json") + s, err := settings.LoadFromFile(settingsPath) + if err != nil { + t.Fatalf("LoadFromFile() error = %v", err) + } + if s.SummaryGeneration == nil { + t.Fatal("expected summary_generation to be persisted") + } + if s.SummaryGeneration.Provider != string(agent.AgentNameCodex) { + t.Fatalf("persisted provider = %q, want %q", s.SummaryGeneration.Provider, agent.AgentNameCodex) + } +} + +func TestFormatSummaryProviderDetails(t *testing.T) { + t.Parallel() + + details := formatSummaryProviderDetails(&checkpointSummaryProvider{ + DisplayName: "Codex", + DisplayModel: "gpt-5", + }) + + if details != "Provider: Codex\nModel: gpt-5\n" { + t.Fatalf("formatSummaryProviderDetails() = %q", details) + } +} diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 99a695f6d..788590131 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -71,11 +71,27 @@ type EntireSettings struct { // plugins (entire-agent-* binaries on $PATH). Defaults to false. ExternalAgents bool `json:"external_agents,omitempty"` + // SummaryGeneration stores provider preferences for explain --generate. + // This is separate from strategy_options.summarize, which controls + // checkpoint auto-summarize behavior. + SummaryGeneration *SummaryGenerationSettings `json:"summary_generation,omitempty"` + // Deprecated: no longer used. Exists to tolerate old settings files // that still contain "strategy": "auto-commit" or similar. Strategy string `json:"strategy,omitempty"` } +// SummaryGenerationSettings configures provider selection for on-demand +// checkpoint summaries generated by explain --generate. +type SummaryGenerationSettings struct { + // Provider is the selected summary provider agent name + // (for example "claude-code", "codex", or "gemini"). + Provider string `json:"provider,omitempty"` + + // Model is an optional model hint passed to the selected provider. + Model string `json:"model,omitempty"` +} + // RedactionSettings configures redaction behavior beyond the default secret detection. type RedactionSettings struct { PII *PIISettings `json:"pii,omitempty"` @@ -253,6 +269,34 @@ func mergeJSON(settings *EntireSettings, data []byte) error { settings.Telemetry = &t } + // Merge summary_generation sub-fields if present. + if summaryRaw, ok := raw["summary_generation"]; ok { + if settings.SummaryGeneration == nil { + settings.SummaryGeneration = &SummaryGenerationSettings{} + } + + var summaryFields map[string]json.RawMessage + if err := json.Unmarshal(summaryRaw, &summaryFields); err != nil { + return fmt.Errorf("parsing summary_generation field: %w", err) + } + + if providerRaw, ok := summaryFields["provider"]; ok { + var provider string + if err := json.Unmarshal(providerRaw, &provider); err != nil { + return fmt.Errorf("parsing summary_generation.provider field: %w", err) + } + settings.SummaryGeneration.Provider = provider + } + + if modelRaw, ok := summaryFields["model"]; ok { + var model string + if err := json.Unmarshal(modelRaw, &model); err != nil { + return fmt.Errorf("parsing summary_generation.model field: %w", err) + } + settings.SummaryGeneration.Model = model + } + } + // Merge redaction sub-fields if present (field-level, not wholesale replace). if redactionRaw, ok := raw["redaction"]; ok { if settings.Redaction == nil { diff --git a/cmd/entire/cli/settings/settings_test.go b/cmd/entire/cli/settings/settings_test.go index bfc52755f..737e35853 100644 --- a/cmd/entire/cli/settings/settings_test.go +++ b/cmd/entire/cli/settings/settings_test.go @@ -59,6 +59,7 @@ func TestLoad_AcceptsValidKeys(t *testing.T) { "local_dev": false, "log_level": "debug", "strategy_options": {"key": "value"}, + "summary_generation": {"provider": "claude-code", "model": "sonnet"}, "telemetry": true, "redaction": {"pii": {"enabled": true, "email": true, "phone": false}}, "external_agents": true @@ -91,6 +92,15 @@ func TestLoad_AcceptsValidKeys(t *testing.T) { if settings.Telemetry == nil || !*settings.Telemetry { t.Error("expected telemetry to be true") } + if settings.SummaryGeneration == nil { + t.Fatal("expected summary_generation to be non-nil") + } + if settings.SummaryGeneration.Provider != "claude-code" { + t.Errorf("expected summary_generation.provider 'claude-code', got %q", settings.SummaryGeneration.Provider) + } + if settings.SummaryGeneration.Model != "sonnet" { + t.Errorf("expected summary_generation.model 'sonnet', got %q", settings.SummaryGeneration.Model) + } if settings.Redaction == nil { t.Fatal("expected redaction to be non-nil") } @@ -447,6 +457,81 @@ func TestMergeJSON_ExternalAgents(t *testing.T) { } } +func TestLoad_SummaryGenerationField(t *testing.T) { + tmpDir := t.TempDir() + + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatalf("failed to create .entire directory: %v", err) + } + + settingsFile := filepath.Join(entireDir, "settings.json") + if err := os.WriteFile(settingsFile, []byte(`{"enabled": true, "summary_generation": {"provider": "codex", "model": "gpt-5"}}`), 0o644); err != nil { + t.Fatalf("failed to write settings file: %v", err) + } + + if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755); err != nil { + t.Fatalf("failed to create .git directory: %v", err) + } + + t.Chdir(tmpDir) + + s, err := Load(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.SummaryGeneration == nil { + t.Fatal("expected SummaryGeneration to be non-nil") + } + if s.SummaryGeneration.Provider != "codex" { + t.Errorf("SummaryGeneration.Provider = %q, want %q", s.SummaryGeneration.Provider, "codex") + } + if s.SummaryGeneration.Model != "gpt-5" { + t.Errorf("SummaryGeneration.Model = %q, want %q", s.SummaryGeneration.Model, "gpt-5") + } +} + +func TestMergeJSON_SummaryGeneration(t *testing.T) { + tmpDir := t.TempDir() + + entireDir := filepath.Join(tmpDir, ".entire") + if err := os.MkdirAll(entireDir, 0o755); err != nil { + t.Fatalf("failed to create .entire directory: %v", err) + } + + settingsFile := filepath.Join(entireDir, "settings.json") + base := `{"enabled": true, "summary_generation": {"provider": "claude-code", "model": "sonnet"}}` + if err := os.WriteFile(settingsFile, []byte(base), 0o644); err != nil { + t.Fatalf("failed to write settings file: %v", err) + } + + localFile := filepath.Join(entireDir, "settings.local.json") + local := `{"summary_generation": {"provider": "codex"}}` + if err := os.WriteFile(localFile, []byte(local), 0o644); err != nil { + t.Fatalf("failed to write local settings file: %v", err) + } + + if err := os.MkdirAll(filepath.Join(tmpDir, ".git"), 0o755); err != nil { + t.Fatalf("failed to create .git directory: %v", err) + } + + t.Chdir(tmpDir) + + s, err := Load(context.Background()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if s.SummaryGeneration == nil { + t.Fatal("expected SummaryGeneration to be non-nil") + } + if s.SummaryGeneration.Provider != "codex" { + t.Errorf("SummaryGeneration.Provider = %q, want %q", s.SummaryGeneration.Provider, "codex") + } + if s.SummaryGeneration.Model != "sonnet" { + t.Errorf("SummaryGeneration.Model = %q, want %q", s.SummaryGeneration.Model, "sonnet") + } +} + func TestIsCheckpointsV2Enabled_DefaultsFalse(t *testing.T) { t.Parallel() s := &EntireSettings{Enabled: true} diff --git a/cmd/entire/cli/summarize/claude.go b/cmd/entire/cli/summarize/claude.go index 7b2e6dd3e..334636a4c 100644 --- a/cmd/entire/cli/summarize/claude.go +++ b/cmd/entire/cli/summarize/claude.go @@ -140,18 +140,7 @@ func (g *ClaudeGenerator) Generate(ctx context.Context, input Input) (*checkpoin } // The result field contains the actual JSON summary - resultJSON := cliResponse.Result - - // Try to extract JSON if it's wrapped in markdown code blocks - resultJSON = extractJSONFromMarkdown(resultJSON) - - // Parse the summary from the result - var summary checkpoint.Summary - if err := json.Unmarshal([]byte(resultJSON), &summary); err != nil { - return nil, fmt.Errorf("failed to parse summary JSON: %w (response: %s)", err, resultJSON) - } - - return &summary, nil + return parseSummaryText(cliResponse.Result) } // buildSummarizationPrompt creates the prompt for the Claude CLI. @@ -171,6 +160,27 @@ func stripGitEnv(env []string) []string { return filtered } +func parseSummaryText(result string) (*checkpoint.Summary, error) { + resultJSON := extractJSONFromMarkdown(result) + + var summary checkpoint.Summary + if err := json.Unmarshal([]byte(resultJSON), &summary); err != nil { + start := strings.Index(resultJSON, "{") + end := strings.LastIndex(resultJSON, "}") + if start >= 0 && end > start { + candidate := resultJSON[start : end+1] + if candidate != resultJSON { + if retryErr := json.Unmarshal([]byte(candidate), &summary); retryErr == nil { + return &summary, nil + } + } + } + return nil, fmt.Errorf("failed to parse summary JSON: %w (response: %s)", err, resultJSON) + } + + return &summary, nil +} + // extractJSONFromMarkdown attempts to extract JSON from markdown code blocks. // If the input is not wrapped in code blocks, it returns the input unchanged. func extractJSONFromMarkdown(s string) string { diff --git a/cmd/entire/cli/summarize/text_generator.go b/cmd/entire/cli/summarize/text_generator.go new file mode 100644 index 000000000..a94776e38 --- /dev/null +++ b/cmd/entire/cli/summarize/text_generator.go @@ -0,0 +1,30 @@ +package summarize + +import ( + "context" + "fmt" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/checkpoint" +) + +// TextGeneratorAdapter uses an agent.TextGenerator with Entire's shared +// summary prompt and response parser. +type TextGeneratorAdapter struct { + TextGenerator agent.TextGenerator + Model string +} + +// Generate creates a summary using the shared prompt, then delegates raw text +// generation to the configured agent provider. +func (g *TextGeneratorAdapter) Generate(ctx context.Context, input Input) (*checkpoint.Summary, error) { + transcriptText := FormatCondensedTranscript(input) + prompt := buildSummarizationPrompt(transcriptText) + + result, err := g.TextGenerator.GenerateText(ctx, prompt, g.Model) + if err != nil { + return nil, fmt.Errorf("provider text generation failed: %w", err) + } + + return parseSummaryText(result) +} diff --git a/cmd/entire/cli/summarize/text_generator_test.go b/cmd/entire/cli/summarize/text_generator_test.go new file mode 100644 index 000000000..930ce9020 --- /dev/null +++ b/cmd/entire/cli/summarize/text_generator_test.go @@ -0,0 +1,74 @@ +package summarize + +import ( + "context" + "strings" + "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" +) + +type mockTextGenerator struct { + prompt string + model string + result string +} + +func (m *mockTextGenerator) Name() types.AgentName { return "mock" } +func (m *mockTextGenerator) Type() types.AgentType { return "Mock" } +func (m *mockTextGenerator) Description() string { return "mock" } +func (m *mockTextGenerator) IsPreview() bool { return false } +func (m *mockTextGenerator) DetectPresence(context.Context) (bool, error) { return false, nil } +func (m *mockTextGenerator) ProtectedDirs() []string { return nil } +func (m *mockTextGenerator) ReadTranscript(string) ([]byte, error) { return nil, nil } +func (m *mockTextGenerator) ChunkTranscript(context.Context, []byte, int) ([][]byte, error) { + return nil, nil +} +func (m *mockTextGenerator) ReassembleTranscript([][]byte) ([]byte, error) { return nil, nil } +func (m *mockTextGenerator) GetSessionID(*agent.HookInput) string { return "" } +func (m *mockTextGenerator) GetSessionDir(string) (string, error) { return "", nil } +func (m *mockTextGenerator) ResolveSessionFile(string, string) string { return "" } +func (m *mockTextGenerator) ReadSession(*agent.HookInput) (*agent.AgentSession, error) { + return nil, nil //nolint:nilnil // test stub +} +func (m *mockTextGenerator) WriteSession(context.Context, *agent.AgentSession) error { return nil } +func (m *mockTextGenerator) FormatResumeCommand(string) string { return "" } +func (m *mockTextGenerator) GenerateText(_ context.Context, prompt string, model string) (string, error) { + m.prompt = prompt + m.model = model + return m.result, nil +} + +func TestTextGeneratorAdapter_Generate(t *testing.T) { + t.Parallel() + + mock := &mockTextGenerator{ + result: "```json\n{\"intent\":\"Intent\",\"outcome\":\"Outcome\",\"learnings\":{\"repo\":[],\"code\":[],\"workflow\":[]},\"friction\":[],\"open_items\":[]}\n```", + } + + generator := &TextGeneratorAdapter{ + TextGenerator: mock, + Model: "test-model", + } + + summary, err := generator.Generate(context.Background(), Input{ + Transcript: []Entry{ + {Type: EntryTypeUser, Content: "Fix the bug"}, + {Type: EntryTypeAssistant, Content: "I fixed it"}, + }, + }) + if err != nil { + t.Fatalf("Generate() error = %v", err) + } + + if summary.Intent != "Intent" { + t.Fatalf("summary.Intent = %q, want %q", summary.Intent, "Intent") + } + if mock.model != "test-model" { + t.Fatalf("GenerateText model = %q, want %q", mock.model, "test-model") + } + if !strings.Contains(mock.prompt, "Fix the bug") { + t.Fatalf("prompt did not include condensed transcript: %q", mock.prompt) + } +} From f5e6650fafb8413fc22e60cdf9b5ae305495b469 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 8 Apr 2026 15:38:30 -0700 Subject: [PATCH 2/6] Add configure flags for summary provider --- cmd/entire/cli/explain_summary_provider.go | 11 +++ cmd/entire/cli/setup.go | 57 ++++++++++++++ cmd/entire/cli/setup_test.go | 86 ++++++++++++++++++++++ 3 files changed, 154 insertions(+) diff --git a/cmd/entire/cli/explain_summary_provider.go b/cmd/entire/cli/explain_summary_provider.go index 3ba8d0c3c..a24e6b2eb 100644 --- a/cmd/entire/cli/explain_summary_provider.go +++ b/cmd/entire/cli/explain_summary_provider.go @@ -156,6 +156,17 @@ func buildCheckpointSummaryProvider(name types.AgentName, model string) (*checkp }, nil } +func validateSummaryProvider(provider string) error { + ag, err := getSummaryAgent(types.AgentName(provider)) + if err != nil { + return fmt.Errorf("unknown summary provider %q", provider) + } + if _, ok := agent.AsTextGenerator(ag); !ok { + return fmt.Errorf("agent %q does not support summary generation", provider) + } + return nil +} + func persistSummaryProviderSelection(ctx context.Context, provider types.AgentName, model string) error { targetFile, _ := settingsTargetFile(ctx, false, false) targetFileAbs, err := paths.AbsPath(ctx, targetFile) diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index d3c996859..a93372d3f 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -34,6 +34,8 @@ const ( agentFlagName = "agent" flagCheckpointRemote = "checkpoint-remote" flagSkipPushSessions = "skip-push-sessions" + flagSummarizeModel = "summarize-model" + flagSummarizeAgent = "summarize-provider" ) // EnableOptions holds the flags for `entire enable`. @@ -76,6 +78,10 @@ func hasStrategyFlags(cmd *cobra.Command) bool { return cmd.Flags().Changed(flagCheckpointRemote) || cmd.Flags().Changed(flagSkipPushSessions) } +func hasSummaryProviderFlags(cmd *cobra.Command) bool { + return cmd.Flags().Changed(flagSummarizeAgent) || cmd.Flags().Changed(flagSummarizeModel) +} + // updateStrategyOptions applies strategy flags to settings without re-running agent setup. // Loads and writes only the target file to avoid leaking settings between layers. func updateStrategyOptions(ctx context.Context, w io.Writer, opts EnableOptions) error { @@ -114,6 +120,50 @@ func updateStrategyOptions(ctx context.Context, w io.Writer, opts EnableOptions) return nil } +func updateSummaryGenerationSettings(ctx context.Context, w io.Writer, provider, model string, opts EnableOptions) error { + if provider == "" && model == "" { + return errors.New("at least one of --summarize-provider or --summarize-model must be set") + } + if provider != "" { + if err := validateSummaryProvider(provider); err != nil { + return err + } + } + + targetFile, configDisplay := settingsTargetFile(ctx, opts.UseLocalSettings, opts.UseProjectSettings) + targetFileAbs, err := paths.AbsPath(ctx, targetFile) + if err != nil { + targetFileAbs = targetFile + } + + s, err := settings.LoadFromFile(targetFileAbs) + if err != nil { + return fmt.Errorf("failed to load settings: %w", err) + } + if s.SummaryGeneration == nil { + s.SummaryGeneration = &settings.SummaryGenerationSettings{} + } + if provider != "" { + s.SummaryGeneration.Provider = provider + } + if model != "" { + s.SummaryGeneration.Model = model + } + + if targetFile == settings.EntireSettingsLocalFile { + if err := SaveEntireSettingsLocal(ctx, s); err != nil { + return fmt.Errorf("failed to save settings: %w", err) + } + } else { + if err := SaveEntireSettings(ctx, s); err != nil { + return fmt.Errorf("failed to save settings: %w", err) + } + } + + fmt.Fprintf(w, "✓ Settings updated (%s)\n", configDisplay) + return nil +} + // settingsTargetFile determines which settings file to write to based on flags // and which files exist. Unlike determineSettingsTarget, this correctly handles // local-only repos by checking for settings.local.json when settings.json is absent. @@ -407,6 +457,8 @@ func newSetupCmd() *cobra.Command { var opts EnableOptions var agentName string var removeAgentName string + var summarizeProvider string + var summarizeModel string cmd := &cobra.Command{ Use: "configure", @@ -454,6 +506,9 @@ Use --remove to remove a specific agent non-interactively: if hasStrategyFlags(cmd) && settings.IsSetUpAny(ctx) { return updateStrategyOptions(ctx, cmd.OutOrStdout(), opts) } + if hasSummaryProviderFlags(cmd) && settings.IsSetUpAny(ctx) { + return updateSummaryGenerationSettings(ctx, cmd.OutOrStdout(), summarizeProvider, summarizeModel, opts) + } // If already set up, show agents and let user add more if settings.IsSetUpAny(ctx) { @@ -474,6 +529,8 @@ Use --remove to remove a specific agent non-interactively: cmd.Flags().BoolVarP(&opts.ForceHooks, "force", "f", false, "Force reinstall hooks (removes existing Entire hooks first)") cmd.Flags().BoolVar(&opts.SkipPushSessions, flagSkipPushSessions, false, "Disable automatic pushing of session logs on git push") cmd.Flags().StringVar(&opts.CheckpointRemote, flagCheckpointRemote, "", "Checkpoint remote in provider:owner/repo format (e.g., github:org/checkpoints-repo)") + cmd.Flags().StringVar(&summarizeProvider, flagSummarizeAgent, "", "Set the provider used by explain --generate (e.g., claude-code, codex, gemini, cursor, copilot-cli)") + cmd.Flags().StringVar(&summarizeModel, flagSummarizeModel, "", "Set the model hint used by explain --generate") cmd.Flags().BoolVar(&opts.Telemetry, "telemetry", true, "Enable anonymous usage analytics") cmd.Flags().BoolVar(&opts.AbsoluteGitHookPath, "absolute-git-hook-path", false, "Embed full binary path in git hooks (for GUI git clients that don't source shell profiles)") diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 30b064532..7ff2be1fe 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -1903,3 +1903,89 @@ func TestConfigureCmd_CheckpointRemote_DoesNotLeakMergedSettings(t *testing.T) { t.Error("log_level from local settings leaked into project settings") } } + +func TestConfigureCmd_SummarizeProvider_UpdatesProjectSettings(t *testing.T) { + setupTestRepo(t) + writeSettings(t, testSettingsEnabled) + + cmd := newSetupCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--summarize-provider", "codex", "--summarize-model", "gpt-5"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("configure --summarize-provider failed: %v", err) + } + + if !strings.Contains(stdout.String(), "Settings updated") { + t.Errorf("expected 'Settings updated' output, got: %s", stdout.String()) + } + + s, err := settings.LoadFromFile(EntireSettingsFile) + if err != nil { + t.Fatalf("failed to load settings: %v", err) + } + if s.SummaryGeneration == nil { + t.Fatal("expected summary_generation to be set") + } + if s.SummaryGeneration.Provider != "codex" { + t.Fatalf("summary provider = %q, want %q", s.SummaryGeneration.Provider, "codex") + } + if s.SummaryGeneration.Model != "gpt-5" { + t.Fatalf("summary model = %q, want %q", s.SummaryGeneration.Model, "gpt-5") + } +} + +func TestConfigureCmd_SummarizeProvider_WritesToLocalFile(t *testing.T) { + setupTestRepo(t) + writeSettings(t, testSettingsEnabled) + + cmd := newSetupCmd() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--local", "--summarize-provider", "claude-code", "--summarize-model", "sonnet"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("configure --local --summarize-provider failed: %v", err) + } + + if !strings.Contains(stdout.String(), "settings.local.json") { + t.Errorf("expected output to reference settings.local.json, got: %s", stdout.String()) + } + + localS, err := settings.LoadFromFile(EntireSettingsLocalFile) + if err != nil { + t.Fatalf("failed to load local settings: %v", err) + } + if localS.SummaryGeneration == nil { + t.Fatal("expected local summary_generation to be set") + } + if localS.SummaryGeneration.Provider != "claude-code" { + t.Fatalf("local summary provider = %q, want %q", localS.SummaryGeneration.Provider, "claude-code") + } + + projectS, err := settings.LoadFromFile(EntireSettingsFile) + if err != nil { + t.Fatalf("failed to load project settings: %v", err) + } + if projectS.SummaryGeneration != nil { + t.Fatal("summary_generation should not leak into project settings") + } +} + +func TestConfigureCmd_SummarizeProvider_InvalidProvider(t *testing.T) { + setupTestRepo(t) + writeSettings(t, testSettingsEnabled) + + cmd := newSetupCmd() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--summarize-provider", "opencode"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for unsupported summary provider") + } +} From fe711b0586dc3c69c9c003b69e509e88daa2476a Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 8 Apr 2026 16:52:49 -0700 Subject: [PATCH 3/6] Fix summary provider model transitions --- cmd/entire/cli/explain_summary_provider.go | 3 + cmd/entire/cli/setup.go | 18 ++++-- cmd/entire/cli/setup_test.go | 71 ++++++++++++++++++++++ 3 files changed, 87 insertions(+), 5 deletions(-) diff --git a/cmd/entire/cli/explain_summary_provider.go b/cmd/entire/cli/explain_summary_provider.go index a24e6b2eb..0ba1e6e08 100644 --- a/cmd/entire/cli/explain_summary_provider.go +++ b/cmd/entire/cli/explain_summary_provider.go @@ -181,6 +181,9 @@ func persistSummaryProviderSelection(ctx context.Context, provider types.AgentNa if s.SummaryGeneration == nil { s.SummaryGeneration = &settings.SummaryGenerationSettings{} } + if s.SummaryGeneration.Provider != "" && s.SummaryGeneration.Provider != string(provider) && model == "" { + s.SummaryGeneration.Model = "" + } s.SummaryGeneration.Provider = string(provider) if model != "" { s.SummaryGeneration.Model = model diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index a93372d3f..dd9ff8c03 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -124,11 +124,6 @@ func updateSummaryGenerationSettings(ctx context.Context, w io.Writer, provider, if provider == "" && model == "" { return errors.New("at least one of --summarize-provider or --summarize-model must be set") } - if provider != "" { - if err := validateSummaryProvider(provider); err != nil { - return err - } - } targetFile, configDisplay := settingsTargetFile(ctx, opts.UseLocalSettings, opts.UseProjectSettings) targetFileAbs, err := paths.AbsPath(ctx, targetFile) @@ -143,7 +138,20 @@ func updateSummaryGenerationSettings(ctx context.Context, w io.Writer, provider, if s.SummaryGeneration == nil { s.SummaryGeneration = &settings.SummaryGenerationSettings{} } + + if provider != "" { + if err := validateSummaryProvider(provider); err != nil { + return err + } + } + if model != "" && provider == "" && s.SummaryGeneration.Provider == "" { + return errors.New("--summarize-model requires an existing summary provider or --summarize-provider") + } + if provider != "" { + if s.SummaryGeneration.Provider != "" && s.SummaryGeneration.Provider != provider && model == "" { + s.SummaryGeneration.Model = "" + } s.SummaryGeneration.Provider = provider } if model != "" { diff --git a/cmd/entire/cli/setup_test.go b/cmd/entire/cli/setup_test.go index 7ff2be1fe..64cd14f99 100644 --- a/cmd/entire/cli/setup_test.go +++ b/cmd/entire/cli/setup_test.go @@ -1989,3 +1989,74 @@ func TestConfigureCmd_SummarizeProvider_InvalidProvider(t *testing.T) { t.Fatal("expected error for unsupported summary provider") } } + +func TestConfigureCmd_SummarizeProvider_SwitchClearsStaleModel(t *testing.T) { + setupTestRepo(t) + writeSettings(t, `{"enabled": true, "summary_generation": {"provider": "claude-code", "model": "sonnet"}}`) + + cmd := newSetupCmd() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--summarize-provider", "codex"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("configure --summarize-provider codex failed: %v", err) + } + + s, err := settings.LoadFromFile(EntireSettingsFile) + if err != nil { + t.Fatalf("failed to load settings: %v", err) + } + if s.SummaryGeneration == nil { + t.Fatal("expected summary_generation to be set") + } + if s.SummaryGeneration.Provider != "codex" { + t.Fatalf("summary provider = %q, want %q", s.SummaryGeneration.Provider, "codex") + } + if s.SummaryGeneration.Model != "" { + t.Fatalf("summary model = %q, want empty after provider switch", s.SummaryGeneration.Model) + } +} + +func TestConfigureCmd_SummarizeModel_RequiresProvider(t *testing.T) { + setupTestRepo(t) + writeSettings(t, testSettingsEnabled) + + cmd := newSetupCmd() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--summarize-model", "sonnet"}) + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for summarize-model without provider") + } +} + +func TestConfigureCmd_SummarizeModel_UsesExistingProvider(t *testing.T) { + setupTestRepo(t) + writeSettings(t, `{"enabled": true, "summary_generation": {"provider": "claude-code"}}`) + + cmd := newSetupCmd() + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--summarize-model", "sonnet"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("configure --summarize-model failed: %v", err) + } + + s, err := settings.LoadFromFile(EntireSettingsFile) + if err != nil { + t.Fatalf("failed to load settings: %v", err) + } + if s.SummaryGeneration == nil { + t.Fatal("expected summary_generation to be set") + } + if s.SummaryGeneration.Provider != "claude-code" { + t.Fatalf("summary provider = %q, want %q", s.SummaryGeneration.Provider, "claude-code") + } + if s.SummaryGeneration.Model != "sonnet" { + t.Fatalf("summary model = %q, want %q", s.SummaryGeneration.Model, "sonnet") + } +} From ece160c851eb6bfeda98fad29f8c55ae68e3df42 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Wed, 8 Apr 2026 20:07:04 -0700 Subject: [PATCH 4/6] Simplify summary provider CLI execution --- cmd/entire/cli/agent/claudecode/generate.go | 50 ++++-------------- cmd/entire/cli/agent/codex/generate.go | 43 +++------------ cmd/entire/cli/agent/copilotcli/generate.go | 40 +++----------- cmd/entire/cli/agent/cursor/generate.go | 39 +++----------- cmd/entire/cli/agent/geminicli/generate.go | 41 +++------------ cmd/entire/cli/agent/text_generator_cli.go | 58 +++++++++++++++++++++ cmd/entire/cli/summarize/claude.go | 53 ++----------------- cmd/entire/cli/summarize/claude_test.go | 4 +- 8 files changed, 100 insertions(+), 228 deletions(-) create mode 100644 cmd/entire/cli/agent/text_generator_cli.go diff --git a/cmd/entire/cli/agent/claudecode/generate.go b/cmd/entire/cli/agent/claudecode/generate.go index 8533375c8..80ac0dd53 100644 --- a/cmd/entire/cli/agent/claudecode/generate.go +++ b/cmd/entire/cli/agent/claudecode/generate.go @@ -1,14 +1,12 @@ package claudecode import ( - "bytes" "context" "encoding/json" - "errors" "fmt" - "os" "os/exec" - "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" ) // GenerateText sends a prompt to the Claude CLI and returns the raw text response. @@ -21,52 +19,22 @@ func (c *ClaudeCodeAgent) GenerateText(ctx context.Context, prompt string, model model = "haiku" } - cmd := exec.CommandContext(ctx, claudePath, + args := []string{ "--print", "--output-format", "json", - "--model", model, "--setting-sources", "") - - // Isolate from the user's git repo to prevent recursive hook triggers - // and index pollution (same approach as summarize/claude.go). - cmd.Dir = os.TempDir() - cmd.Env = stripGitEnv(os.Environ()) - cmd.Stdin = strings.NewReader(prompt) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - var execErr *exec.Error - if errors.As(err, &execErr) { - return "", fmt.Errorf("claude CLI not found: %w", err) - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return "", fmt.Errorf("claude CLI failed (exit %d): %s", exitErr.ExitCode(), stderr.String()) - } - return "", fmt.Errorf("failed to run claude CLI: %w", err) + "--model", model, "--setting-sources", "", + } + stdoutText, err := agent.RunIsolatedTextGeneratorCLI(ctx, exec.CommandContext, claudePath, "claude", args, prompt) + if err != nil { + return "", fmt.Errorf("claude text generation failed: %w", err) } // Parse the {"result": "..."} envelope var response struct { Result string `json:"result"` } - if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + if err := json.Unmarshal([]byte(stdoutText), &response); err != nil { return "", fmt.Errorf("failed to parse claude CLI response: %w", err) } return response.Result, nil } - -// stripGitEnv returns a copy of env with all GIT_* variables removed. -// This prevents a subprocess from discovering or modifying the parent's git repo. -// Duplicated from summarize/claude.go — simple filter not worth extracting to shared package. -func stripGitEnv(env []string) []string { - filtered := make([]string, 0, len(env)) - for _, e := range env { - if !strings.HasPrefix(e, "GIT_") { - filtered = append(filtered, e) - } - } - return filtered -} diff --git a/cmd/entire/cli/agent/codex/generate.go b/cmd/entire/cli/agent/codex/generate.go index 364725be6..c1debe9d8 100644 --- a/cmd/entire/cli/agent/codex/generate.go +++ b/cmd/entire/cli/agent/codex/generate.go @@ -1,55 +1,26 @@ package codex import ( - "bytes" "context" - "errors" "fmt" - "os" "os/exec" - "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" ) var codexCommandRunner = exec.CommandContext // GenerateText sends a prompt to the Codex CLI and returns the raw text response. func (c *CodexAgent) GenerateText(ctx context.Context, prompt string, model string) (string, error) { - args := []string{"exec"} + args := []string{"exec", "--skip-git-repo-check"} if model != "" { args = append(args, "--model", model) } args = append(args, "-") - cmd := codexCommandRunner(ctx, "codex", args...) - cmd.Dir = os.TempDir() - cmd.Env = stripGitEnv(os.Environ()) - cmd.Stdin = strings.NewReader(prompt) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - var execErr *exec.Error - if errors.As(err, &execErr) { - return "", fmt.Errorf("codex CLI not found: %w", err) - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return "", fmt.Errorf("codex CLI failed (exit %d): %s", exitErr.ExitCode(), stderr.String()) - } - return "", fmt.Errorf("failed to run codex CLI: %w", err) - } - - return strings.TrimSpace(stdout.String()), nil -} - -func stripGitEnv(env []string) []string { - filtered := make([]string, 0, len(env)) - for _, e := range env { - if !strings.HasPrefix(e, "GIT_") { - filtered = append(filtered, e) - } + result, err := agent.RunIsolatedTextGeneratorCLI(ctx, codexCommandRunner, "codex", "codex", args, prompt) + if err != nil { + return "", fmt.Errorf("codex text generation failed: %w", err) } - return filtered + return result, nil } diff --git a/cmd/entire/cli/agent/copilotcli/generate.go b/cmd/entire/cli/agent/copilotcli/generate.go index 33e223441..8ffc7fee4 100644 --- a/cmd/entire/cli/agent/copilotcli/generate.go +++ b/cmd/entire/cli/agent/copilotcli/generate.go @@ -1,13 +1,11 @@ package copilotcli import ( - "bytes" "context" - "errors" "fmt" - "os" "os/exec" - "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" ) var copilotCommandRunner = exec.CommandContext @@ -19,35 +17,9 @@ func (c *CopilotCLIAgent) GenerateText(ctx context.Context, prompt string, model args = append(args, "--model", model) } - cmd := copilotCommandRunner(ctx, "copilot", args...) - cmd.Dir = os.TempDir() - cmd.Env = stripGitEnv(os.Environ()) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - var execErr *exec.Error - if errors.As(err, &execErr) { - return "", fmt.Errorf("copilot CLI not found: %w", err) - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return "", fmt.Errorf("copilot CLI failed (exit %d): %s", exitErr.ExitCode(), stderr.String()) - } - return "", fmt.Errorf("failed to run copilot CLI: %w", err) - } - - return strings.TrimSpace(stdout.String()), nil -} - -func stripGitEnv(env []string) []string { - filtered := make([]string, 0, len(env)) - for _, e := range env { - if !strings.HasPrefix(e, "GIT_") { - filtered = append(filtered, e) - } + result, err := agent.RunIsolatedTextGeneratorCLI(ctx, copilotCommandRunner, "copilot", "copilot", args, "") + if err != nil { + return "", fmt.Errorf("copilot text generation failed: %w", err) } - return filtered + return result, nil } diff --git a/cmd/entire/cli/agent/cursor/generate.go b/cmd/entire/cli/agent/cursor/generate.go index 79105fc07..160b7c93a 100644 --- a/cmd/entire/cli/agent/cursor/generate.go +++ b/cmd/entire/cli/agent/cursor/generate.go @@ -1,13 +1,12 @@ package cursor import ( - "bytes" "context" - "errors" "fmt" "os" "os/exec" - "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" ) var cursorCommandRunner = exec.CommandContext @@ -19,35 +18,9 @@ func (c *CursorAgent) GenerateText(ctx context.Context, prompt string, model str args = append(args, "--model", model) } - cmd := cursorCommandRunner(ctx, "agent", args...) - cmd.Dir = os.TempDir() - cmd.Env = stripGitEnv(os.Environ()) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - var execErr *exec.Error - if errors.As(err, &execErr) { - return "", fmt.Errorf("cursor CLI not found: %w", err) - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return "", fmt.Errorf("cursor CLI failed (exit %d): %s", exitErr.ExitCode(), stderr.String()) - } - return "", fmt.Errorf("failed to run cursor CLI: %w", err) - } - - return strings.TrimSpace(stdout.String()), nil -} - -func stripGitEnv(env []string) []string { - filtered := make([]string, 0, len(env)) - for _, e := range env { - if !strings.HasPrefix(e, "GIT_") { - filtered = append(filtered, e) - } + result, err := agent.RunIsolatedTextGeneratorCLI(ctx, cursorCommandRunner, "agent", "cursor", args, "") + if err != nil { + return "", fmt.Errorf("cursor text generation failed: %w", err) } - return filtered + return result, nil } diff --git a/cmd/entire/cli/agent/geminicli/generate.go b/cmd/entire/cli/agent/geminicli/generate.go index 40e8f694e..81795f575 100644 --- a/cmd/entire/cli/agent/geminicli/generate.go +++ b/cmd/entire/cli/agent/geminicli/generate.go @@ -1,13 +1,11 @@ package geminicli import ( - "bytes" "context" - "errors" "fmt" - "os" "os/exec" - "strings" + + "github.com/entireio/cli/cmd/entire/cli/agent" ) var geminiCommandRunner = exec.CommandContext @@ -19,36 +17,9 @@ func (g *GeminiCLIAgent) GenerateText(ctx context.Context, prompt string, model args = append(args, "--model", model) } - cmd := geminiCommandRunner(ctx, "gemini", args...) - cmd.Dir = os.TempDir() - cmd.Env = stripGitEnv(os.Environ()) - cmd.Stdin = strings.NewReader(prompt) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - var execErr *exec.Error - if errors.As(err, &execErr) { - return "", fmt.Errorf("gemini CLI not found: %w", err) - } - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return "", fmt.Errorf("gemini CLI failed (exit %d): %s", exitErr.ExitCode(), stderr.String()) - } - return "", fmt.Errorf("failed to run gemini CLI: %w", err) - } - - return strings.TrimSpace(stdout.String()), nil -} - -func stripGitEnv(env []string) []string { - filtered := make([]string, 0, len(env)) - for _, e := range env { - if !strings.HasPrefix(e, "GIT_") { - filtered = append(filtered, e) - } + result, err := agent.RunIsolatedTextGeneratorCLI(ctx, geminiCommandRunner, "gemini", "gemini", args, prompt) + if err != nil { + return "", fmt.Errorf("gemini text generation failed: %w", err) } - return filtered + return result, nil } diff --git a/cmd/entire/cli/agent/text_generator_cli.go b/cmd/entire/cli/agent/text_generator_cli.go new file mode 100644 index 000000000..4e3e229cb --- /dev/null +++ b/cmd/entire/cli/agent/text_generator_cli.go @@ -0,0 +1,58 @@ +package agent + +import ( + "bytes" + "context" + "errors" + "fmt" + "os" + "os/exec" + "strings" +) + +// TextCommandRunner matches exec.CommandContext and allows tests to inject a runner. +type TextCommandRunner func(ctx context.Context, name string, args ...string) *exec.Cmd + +// RunIsolatedTextGeneratorCLI executes a text-generation CLI in an isolated temp +// directory with all GIT_* environment variables removed. This avoids recursive +// hook triggers and repo side effects while preserving provider-specific flags. +func RunIsolatedTextGeneratorCLI(ctx context.Context, runner TextCommandRunner, binary, displayName string, args []string, stdin string) (string, error) { + if runner == nil { + runner = exec.CommandContext + } + + cmd := runner(ctx, binary, args...) + cmd.Dir = os.TempDir() + cmd.Env = StripGitEnv(os.Environ()) + if stdin != "" { + cmd.Stdin = strings.NewReader(stdin) + } + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + var execErr *exec.Error + if errors.As(err, &execErr) { + return "", fmt.Errorf("%s CLI not found: %w", displayName, err) + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return "", fmt.Errorf("%s CLI failed (exit %d): %s", displayName, exitErr.ExitCode(), stderr.String()) + } + return "", fmt.Errorf("failed to run %s CLI: %w", displayName, err) + } + + return strings.TrimSpace(stdout.String()), nil +} + +func StripGitEnv(env []string) []string { + filtered := make([]string, 0, len(env)) + for _, e := range env { + if !strings.HasPrefix(e, "GIT_") { + filtered = append(filtered, e) + } + } + return filtered +} diff --git a/cmd/entire/cli/summarize/claude.go b/cmd/entire/cli/summarize/claude.go index 334636a4c..03247f3a1 100644 --- a/cmd/entire/cli/summarize/claude.go +++ b/cmd/entire/cli/summarize/claude.go @@ -1,15 +1,13 @@ package summarize import ( - "bytes" "context" "encoding/json" - "errors" "fmt" - "os" "os/exec" "strings" + "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/checkpoint" ) @@ -98,44 +96,15 @@ func (g *ClaudeGenerator) Generate(ctx context.Context, input Input) (*checkpoin // Use empty --setting-sources to skip all settings (user, project, local). // This avoids loading MCP servers, hooks, or other config that could interfere // with a simple --print summarization call. - cmd := runner(ctx, claudePath, "--print", "--output-format", "json", "--model", model, "--setting-sources", "") - - // Fully isolate the subprocess from the user's git repo (ENT-242). - // Claude Code performs internal git operations (plugin cache, context gathering) - // that pollute the worktree index with phantom entries from its plugin cache. - // We must both change the working directory AND strip GIT_* env vars, because - // git hooks set GIT_DIR which lets Claude Code find the repo regardless of cwd. - // This also prevents recursive triggering of Entire's own git hooks. - cmd.Dir = os.TempDir() - cmd.Env = stripGitEnv(os.Environ()) - - // Pass prompt via stdin - cmd.Stdin = strings.NewReader(prompt) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() + args := []string{"--print", "--output-format", "json", "--model", model, "--setting-sources", ""} + stdoutText, err := agent.RunIsolatedTextGeneratorCLI(ctx, runner, claudePath, "claude", args, prompt) if err != nil { - // Check if the command was not found - var execErr *exec.Error - if errors.As(err, &execErr) { - return nil, fmt.Errorf("claude CLI not found: %w", err) - } - - // Check for exit error - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return nil, fmt.Errorf("claude CLI failed (exit %d): %s", exitErr.ExitCode(), stderr.String()) - } - - return nil, fmt.Errorf("failed to run claude CLI: %w", err) + return nil, fmt.Errorf("claude summary generation failed: %w", err) } // Parse the CLI response var cliResponse claudeCLIResponse - if err := json.Unmarshal(stdout.Bytes(), &cliResponse); err != nil { + if err := json.Unmarshal([]byte(stdoutText), &cliResponse); err != nil { return nil, fmt.Errorf("failed to parse claude CLI response: %w", err) } @@ -148,18 +117,6 @@ func buildSummarizationPrompt(transcriptText string) string { return fmt.Sprintf(summarizationPromptTemplate, transcriptText) } -// stripGitEnv returns a copy of env with all GIT_* variables removed. -// This prevents a subprocess from discovering or modifying the parent's git repo. -func stripGitEnv(env []string) []string { - filtered := make([]string, 0, len(env)) - for _, e := range env { - if !strings.HasPrefix(e, "GIT_") { - filtered = append(filtered, e) - } - } - return filtered -} - func parseSummaryText(result string) (*checkpoint.Summary, error) { resultJSON := extractJSONFromMarkdown(result) diff --git a/cmd/entire/cli/summarize/claude_test.go b/cmd/entire/cli/summarize/claude_test.go index aa4518540..2296148cb 100644 --- a/cmd/entire/cli/summarize/claude_test.go +++ b/cmd/entire/cli/summarize/claude_test.go @@ -6,6 +6,8 @@ import ( "os/exec" "strings" "testing" + + "github.com/entireio/cli/cmd/entire/cli/agent" ) func TestClaudeGenerator_GitIsolation(t *testing.T) { @@ -65,7 +67,7 @@ func TestStripGitEnv(t *testing.T) { "SHELL=/bin/zsh", } - filtered := stripGitEnv(env) + filtered := agent.StripGitEnv(env) expected := []string{ "HOME=/Users/test", From ecf914f0b12a545fb5905db81411657be4640587 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 9 Apr 2026 17:59:44 -0700 Subject: [PATCH 5/6] Fix configure flag precedence and add provider fallback logging - Process both --checkpoint-remote and --summarize-provider flags when set together instead of silently dropping summary provider flags - Log when falling back to Claude Code (no providers installed, or non-interactive mode with multiple providers) - Make persistSummaryProviderSelection failure non-fatal: log warning and continue with resolved provider instead of aborting Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: 6aa3fd749d78 --- cmd/entire/cli/explain_summary_provider.go | 9 +++++++-- cmd/entire/cli/setup.go | 19 +++++++++++++------ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/cmd/entire/cli/explain_summary_provider.go b/cmd/entire/cli/explain_summary_provider.go index 0ba1e6e08..da64bb999 100644 --- a/cmd/entire/cli/explain_summary_provider.go +++ b/cmd/entire/cli/explain_summary_provider.go @@ -8,6 +8,7 @@ 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/logging" "github.com/entireio/cli/cmd/entire/cli/paths" "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/summarize" @@ -49,6 +50,7 @@ func resolveCheckpointSummaryProvider(ctx context.Context, w io.Writer) (*checkp switch len(candidates) { case 0: + logging.Info(ctx, "no summary-capable agents installed, falling back to Claude Code") return buildCheckpointSummaryProvider(agent.AgentNameClaudeCode, "") case 1: provider, err := buildCheckpointSummaryProvider(candidates[0].Name, "") @@ -56,11 +58,13 @@ func resolveCheckpointSummaryProvider(ctx context.Context, w io.Writer) (*checkp return nil, err } if saveErr := persistSummaryProviderSelection(ctx, provider.Name, provider.Model); saveErr != nil { - return nil, saveErr + logging.Warn(ctx, "failed to save summary provider selection, continuing without persistence", + "error", saveErr.Error()) } return provider, nil default: if !canPromptInteractively() { + logging.Info(ctx, "non-interactive mode with multiple summary providers, falling back to Claude Code") return buildCheckpointSummaryProvider(agent.AgentNameClaudeCode, "") } @@ -74,7 +78,8 @@ func resolveCheckpointSummaryProvider(ctx context.Context, w io.Writer) (*checkp return nil, err } if saveErr := persistSummaryProviderSelection(ctx, provider.Name, provider.Model); saveErr != nil { - return nil, saveErr + logging.Warn(ctx, "failed to save summary provider selection, continuing without persistence", + "error", saveErr.Error()) } fmt.Fprintf(w, "Using %s for summary generation.\n", provider.DisplayName) return provider, nil diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index dd9ff8c03..7c9772099 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -510,12 +510,19 @@ Use --remove to remove a specific agent non-interactively: return setupAgentHooksNonInteractive(ctx, cmd.OutOrStdout(), ag, opts) } - // Settings-only mode: update strategy options without agent selection - if hasStrategyFlags(cmd) && settings.IsSetUpAny(ctx) { - return updateStrategyOptions(ctx, cmd.OutOrStdout(), opts) - } - if hasSummaryProviderFlags(cmd) && settings.IsSetUpAny(ctx) { - return updateSummaryGenerationSettings(ctx, cmd.OutOrStdout(), summarizeProvider, summarizeModel, opts) + // Settings-only mode: update strategy options / summary provider without agent selection + if settings.IsSetUpAny(ctx) && (hasStrategyFlags(cmd) || hasSummaryProviderFlags(cmd)) { + if hasStrategyFlags(cmd) { + if err := updateStrategyOptions(ctx, cmd.OutOrStdout(), opts); err != nil { + return err + } + } + if hasSummaryProviderFlags(cmd) { + if err := updateSummaryGenerationSettings(ctx, cmd.OutOrStdout(), summarizeProvider, summarizeModel, opts); err != nil { + return err + } + } + return nil } // If already set up, show agents and let user add more From 9c585b320fcb84dda0f03fac22036efc812c5762 Mon Sep 17 00:00:00 2001 From: Peyton Montei Date: Thu, 9 Apr 2026 18:39:40 -0700 Subject: [PATCH 6/6] Wrap original error in validateSummaryProvider, fall back to stdout on empty stderr - validateSummaryProvider now wraps the original agent.Get error so users see why a provider failed to load, not just "unknown summary provider" - RunIsolatedTextGeneratorCLI falls back to stdout when stderr is empty for exit errors, since some CLIs write error details to stdout Co-Authored-By: Claude Opus 4.6 (1M context) Entire-Checkpoint: 60b2a92364f1 --- cmd/entire/cli/agent/text_generator_cli.go | 6 +++++- cmd/entire/cli/explain.go | 4 +--- cmd/entire/cli/explain_summary_provider.go | 2 +- cmd/entire/cli/summarize/summarize.go | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/cmd/entire/cli/agent/text_generator_cli.go b/cmd/entire/cli/agent/text_generator_cli.go index 4e3e229cb..3dd3a2ac4 100644 --- a/cmd/entire/cli/agent/text_generator_cli.go +++ b/cmd/entire/cli/agent/text_generator_cli.go @@ -39,7 +39,11 @@ func RunIsolatedTextGeneratorCLI(ctx context.Context, runner TextCommandRunner, } var exitErr *exec.ExitError if errors.As(err, &exitErr) { - return "", fmt.Errorf("%s CLI failed (exit %d): %s", displayName, exitErr.ExitCode(), stderr.String()) + detail := stderr.String() + if detail == "" { + detail = stdout.String() + } + return "", fmt.Errorf("%s CLI failed (exit %d): %s", displayName, exitErr.ExitCode(), detail) } return "", fmt.Errorf("failed to run %s CLI: %w", displayName, err) } diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index 105937ddb..b36bd1a4f 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -578,9 +578,7 @@ func scopeTranscriptForCheckpoint(fullTranscript []byte, startOffset int, agentT return nil } return scoped - case agent.AgentTypeCodex: - return transcript.SliceFromLine(fullTranscript, startOffset) - case agent.AgentTypeClaudeCode, agent.AgentTypeCursor, agent.AgentTypeFactoryAIDroid, agent.AgentTypeUnknown: + case agent.AgentTypeCodex, agent.AgentTypeClaudeCode, agent.AgentTypeCursor, agent.AgentTypeFactoryAIDroid, agent.AgentTypeUnknown: return transcript.SliceFromLine(fullTranscript, startOffset) } return transcript.SliceFromLine(fullTranscript, startOffset) diff --git a/cmd/entire/cli/explain_summary_provider.go b/cmd/entire/cli/explain_summary_provider.go index da64bb999..dd165db84 100644 --- a/cmd/entire/cli/explain_summary_provider.go +++ b/cmd/entire/cli/explain_summary_provider.go @@ -164,7 +164,7 @@ func buildCheckpointSummaryProvider(name types.AgentName, model string) (*checkp func validateSummaryProvider(provider string) error { ag, err := getSummaryAgent(types.AgentName(provider)) if err != nil { - return fmt.Errorf("unknown summary provider %q", provider) + return fmt.Errorf("unknown summary provider %q: %w", provider, err) } if _, ok := agent.AsTextGenerator(ag); !ok { return fmt.Errorf("agent %q does not support summary generation", provider) diff --git a/cmd/entire/cli/summarize/summarize.go b/cmd/entire/cli/summarize/summarize.go index 7d5ae525d..ea47ed0e1 100644 --- a/cmd/entire/cli/summarize/summarize.go +++ b/cmd/entire/cli/summarize/summarize.go @@ -320,7 +320,7 @@ func extractOpenCodeToolDetail(input map[string]interface{}) string { } // extractGenericToolDetail extracts an appropriate detail string from a tool's input/args map. -// Checks common fields in order of preference. Used by Gemini condensation. +// Checks common fields in order of preference. func extractGenericToolDetail(input map[string]interface{}) string { if input == nil { return ""