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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 9 additions & 41 deletions cmd/entire/cli/agent/claudecode/generate.go
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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
}
26 changes: 26 additions & 0 deletions cmd/entire/cli/agent/codex/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package codex

import (
"context"
"fmt"
"os/exec"

"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", "--skip-git-repo-check"}
if model != "" {
args = append(args, "--model", model)
}
args = append(args, "-")

result, err := agent.RunIsolatedTextGeneratorCLI(ctx, codexCommandRunner, "codex", "codex", args, prompt)
if err != nil {
return "", fmt.Errorf("codex text generation failed: %w", err)
}
return result, nil
}
25 changes: 25 additions & 0 deletions cmd/entire/cli/agent/copilotcli/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package copilotcli

import (
"context"
"fmt"
"os/exec"

"github.com/entireio/cli/cmd/entire/cli/agent"
)

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"}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot and Cursor pass large prompts as CLI arguments

Medium Severity

The Copilot and Cursor GenerateText implementations embed the full prompt as a command-line argument via -p. When used through TextGeneratorAdapter for summarization, this prompt includes the entire formatted transcript, which can be very large. This can exceed OS argument length limits (e.g., ~256 KB on macOS), causing the exec call to fail. Other providers (Claude Code, Codex, Gemini) correctly pass the prompt via stdin.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit c3d84bc. Configure here.

if model != "" {
args = append(args, "--model", model)
}

result, err := agent.RunIsolatedTextGeneratorCLI(ctx, copilotCommandRunner, "copilot", "copilot", args, "")
if err != nil {
return "", fmt.Errorf("copilot text generation failed: %w", err)
}
return result, nil
}
26 changes: 26 additions & 0 deletions cmd/entire/cli/agent/cursor/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package cursor

import (
"context"
"fmt"
"os"
"os/exec"

"github.com/entireio/cli/cmd/entire/cli/agent"
)

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)
}

result, err := agent.RunIsolatedTextGeneratorCLI(ctx, cursorCommandRunner, "agent", "cursor", args, "")
if err != nil {
return "", fmt.Errorf("cursor text generation failed: %w", err)
}
return result, nil
}
25 changes: 25 additions & 0 deletions cmd/entire/cli/agent/geminicli/generate.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package geminicli

import (
"context"
"fmt"
"os/exec"

"github.com/entireio/cli/cmd/entire/cli/agent"
)

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)
}

result, err := agent.RunIsolatedTextGeneratorCLI(ctx, geminiCommandRunner, "gemini", "gemini", args, prompt)
if err != nil {
Comment on lines +14 to +21
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gemini CLI prompt is currently passed via stdin while the args use -p "". In this repo’s E2E harness, Gemini expects the prompt to be provided as the -p <prompt> argument (and typically includes -y plus ACCESSIBLE=1 to avoid interactive/TUI flows). As written, this is likely to generate an empty prompt or hang on interactivity. Update the invocation to pass prompt in the args (and add the non-interactive flags/env needed for reliable headless execution).

Copilot uses AI. Check for mistakes.
return "", fmt.Errorf("gemini text generation failed: %w", err)
}
return result, nil
}
62 changes: 62 additions & 0 deletions cmd/entire/cli/agent/text_generator_cli.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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) {
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)
}

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
}
12 changes: 8 additions & 4 deletions cmd/entire/cli/explain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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
}

Expand Down Expand Up @@ -572,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)
Expand Down
Loading
Loading