package executor import ( "context" "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 } // 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)) outputStr := strings.TrimSpace(string(fullOutput)) // Trim leading/trailing whitespace/newlines from the whole output jsonContent := outputStr // Default to raw output if no markdown block is found or malformed jsonStartIdx := strings.Index(outputStr, "```json") if jsonStartIdx != -1 { // Found "```json", now look for the closing "```" jsonEndIdx := strings.LastIndex(outputStr, "```") if jsonEndIdx != -1 && jsonEndIdx > jsonStartIdx { // Extract content between the markdown fences. jsonContent = outputStr[jsonStartIdx+len("```json"):jsonEndIdx] jsonContent = strings.TrimSpace(jsonContent) // Trim again after extraction, to remove potential inner newlines } else { logger.Warn("Malformed markdown JSON block from Gemini (missing closing ``` or invalid structure), falling back to raw output.", "outputLength", len(outputStr)) } } else { logger.Warn("No markdown JSON block found from Gemini, falling back to raw output.", "outputLength", len(outputStr)) } // Write the (possibly extracted and trimmed) JSON content to the writer. _, writeErr := w.Write([]byte(jsonContent)) if writeErr != nil { return 0, fmt.Errorf("writing extracted gemini json: %w", writeErr) } return 0, nil // For now, no cost/error parsing for Gemini stream } 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 }