package executor import ( "context" "encoding/json" "fmt" "io" "log/slog" "os" "path/filepath" "strings" "sync" "github.com/thepeterstone/claudomator/internal/storage" "github.com/thepeterstone/claudomator/internal/task" ) // GeminiRunner spawns the `gemini` CLI in non-interactive mode. type GeminiRunner struct { BinaryPath string // defaults to "gemini" Logger *slog.Logger LogDir string // base directory for execution logs APIURL string // base URL of the Claudomator API, passed to subprocesses DropsDir string // path to the drops directory, passed to subprocesses } // ExecLogDir returns the log directory for the given execution ID. func (r *GeminiRunner) ExecLogDir(execID string) string { if r.LogDir == "" { return "" } return filepath.Join(r.LogDir, execID) } func (r *GeminiRunner) binaryPath() string { if r.BinaryPath != "" { return r.BinaryPath } return "gemini" } // Run executes a gemini invocation, streaming output to log files. func (r *GeminiRunner) Run(ctx context.Context, t *task.Task, e *storage.Execution) error { if t.Agent.ProjectDir != "" { if _, err := os.Stat(t.Agent.ProjectDir); err != nil { return fmt.Errorf("project_dir %q: %w", t.Agent.ProjectDir, err) } } logDir := r.ExecLogDir(e.ID) if logDir == "" { logDir = e.ID } if err := os.MkdirAll(logDir, 0700); err != nil { return fmt.Errorf("creating log dir: %w", err) } if e.StdoutPath == "" { e.StdoutPath = filepath.Join(logDir, "stdout.log") e.StderrPath = filepath.Join(logDir, "stderr.log") e.ArtifactDir = logDir } if e.SessionID == "" { e.SessionID = e.ID } questionFile := filepath.Join(logDir, "question.json") args := r.buildArgs(t, e, questionFile) // Gemini CLI doesn't necessarily have the same rate limiting behavior as Claude, // but we'll use a similar execution pattern. err := r.execOnce(ctx, args, t.Agent.ProjectDir, t.Agent.ProjectDir, e) if err != nil { return err } // Check whether the agent left a question before exiting. data, readErr := os.ReadFile(questionFile) if readErr == nil { os.Remove(questionFile) // consumed return &BlockedError{QuestionJSON: strings.TrimSpace(string(data)), SessionID: e.SessionID} } return nil } func (r *GeminiRunner) execOnce(ctx context.Context, args []string, workingDir, projectDir string, e *storage.Execution) error { // Temporarily bypass external command execution to debug pipe. // We will simulate outputting to stdoutW directly. stdoutFile, err := os.Create(e.StdoutPath) if err != nil { return fmt.Errorf("creating stdout log: %w", err) } defer stdoutFile.Close() stderrFile, err := os.Create(e.StderrPath) if err != nil { return fmt.Errorf("creating stderr log: %w", err) } defer stderrFile.Close() stdoutR, stdoutW, err := os.Pipe() if err != nil { return fmt.Errorf("creating stdout pipe: %w", err) } // Simulate writing to stdoutW go func() { defer stdoutW.Close() // Close the writer when done. fmt.Fprintf(stdoutW, "```json\n") fmt.Fprintf(stdoutW, "{\"type\":\"content_block_start\",\"content_block\":{\"text\":\"Hello, Gemini!\",\"type\":\"text\"}}\n") fmt.Fprintf(stdoutW, "{\"type\":\"content_block_delta\",\"content_block\":{\"text\":\" How are you?\"}}\n") fmt.Fprintf(stdoutW, "{\"type\":\"content_block_end\"}\n") fmt.Fprintf(stdoutW, "{\"type\":\"message_delta\",\"message\":{\"role\":\"model\"}}\n") fmt.Fprintf(stdoutW, "{\"type\":\"message_end\"}\n") fmt.Fprintf(stdoutW, "```\n") }() var streamErr error var wg sync.WaitGroup wg.Add(1) go func() { defer wg.Done() _, streamErr = parseGeminiStream(stdoutR, stdoutFile, r.Logger) stdoutR.Close() }() wg.Wait() // Wait for parseGeminiStream to finish // Set a dummy exit code for this simulated run e.ExitCode = 0 if streamErr != nil { return streamErr } return nil } // parseGeminiStream reads streaming JSON from the gemini CLI, unwraps markdown // code blocks, writes the inner JSON to w, and returns (costUSD, error). // For now, it focuses on unwrapping and writing, not detailed parsing of cost/errors. func parseGeminiStream(r io.Reader, w io.Writer, logger *slog.Logger) (float64, error) { fullOutput, err := io.ReadAll(r) if err != nil { return 0, fmt.Errorf("reading full gemini output: %w", err) } logger.Debug("parseGeminiStream: raw output received", "output", string(fullOutput)) // Default: write raw content as-is (preserves trailing newline). jsonContent := string(fullOutput) // Unwrap markdown code fences if present. trimmed := strings.TrimSpace(jsonContent) if jsonStartIdx := strings.Index(trimmed, "```json"); jsonStartIdx != -1 { if jsonEndIdx := strings.LastIndex(trimmed, "```"); jsonEndIdx != -1 && jsonEndIdx > jsonStartIdx { inner := trimmed[jsonStartIdx+len("```json") : jsonEndIdx] jsonContent = strings.TrimSpace(inner) + "\n" } else { logger.Warn("malformed markdown JSON block from Gemini, falling back to raw output", "outputLength", len(jsonContent)) } } // Write the (possibly extracted) JSON content to the writer. if _, writeErr := w.Write([]byte(jsonContent)); writeErr != nil { return 0, fmt.Errorf("writing extracted gemini json: %w", writeErr) } // Parse each line for result type to extract cost and execution errors. var resultErr error var costUSD float64 for _, line := range strings.Split(jsonContent, "\n") { line = strings.TrimSpace(line) if line == "" { continue } var msg struct { Type string `json:"type"` IsError bool `json:"is_error"` Result string `json:"result"` Cost float64 `json:"total_cost_usd"` } if err := json.Unmarshal([]byte(line), &msg); err != nil { continue } if msg.Type == "result" { costUSD = msg.Cost if msg.IsError { resultErr = fmt.Errorf("gemini execution error: %s", msg.Result) } } } return costUSD, resultErr } func (r *GeminiRunner) buildArgs(t *task.Task, e *storage.Execution, questionFile string) []string { // Gemini CLI uses a different command structure: gemini "instructions" [flags] instructions := t.Agent.Instructions if !t.Agent.SkipPlanning { instructions = withPlanningPreamble(instructions) } args := []string{ "-p", instructions, "--output-format", "stream-json", "--yolo", // auto-approve all tools (equivalent to Claude's bypassPermissions) } // Note: Gemini CLI flags might differ from Claude CLI. // Assuming common flags for now, but these may need adjustment. if t.Agent.Model != "" { args = append(args, "--model", t.Agent.Model) } // Gemini CLI doesn't use --session-id for the first run in the same way, // or it might use it differently. For now we assume compatibility. if e.SessionID != "" { // If it's a resume, it might use different flags. if e.ResumeSessionID != "" { // This is a placeholder for Gemini's resume logic } } return args }