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
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,9 @@ See the [documentation](https://minicodemonkey.github.io/chief/concepts/how-it-w

## Requirements

- **[Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)**, **[Codex CLI](https://developers.openai.com/codex/cli/reference)**, or **[OpenCode CLI](https://opencode.ai)** installed and authenticated
- **[Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code)**, **[Codex CLI](https://developers.openai.com/codex/cli/reference)**, **[OpenCode CLI](https://opencode.ai)**, or **[Gemini CLI](https://github.com/google-gemini/gemini-cli)** installed and authenticated

Use Claude by default, or configure Codex or OpenCode in `.chief/config.yaml`:
Use Claude by default, or configure another provider in `.chief/config.yaml`:

```yaml
agent:
Expand All @@ -56,6 +56,8 @@ agent:

Or run with `chief --agent opencode` or set `CHIEF_AGENT=opencode`.

Supported values for `provider` are: `claude`, `codex`, `opencode`, `cursor`, and `gemini`.

## License

MIT
Expand Down
4 changes: 2 additions & 2 deletions cmd/chief/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ func parseAgentFlags(args []string, startIdx int) (agentName, agentPath string,
i++
agentName = args[i]
} else {
fmt.Fprintf(os.Stderr, "Error: --agent requires a value (claude, codex, opencode, or cursor)\n")
fmt.Fprintf(os.Stderr, "Error: --agent requires a value (claude, codex, opencode, cursor or gemini)\n")
os.Exit(1)
}
case strings.HasPrefix(arg, "--agent="):
Expand Down Expand Up @@ -514,7 +514,7 @@ Commands:
help Show this help message

Global Options:
--agent <provider> Agent CLI to use: claude (default), codex, opencode, or cursor
--agent <provider> Agent CLI to use: claude (default), codex, opencode, cursor, or gemini
--agent-path <path> Custom path to agent CLI binary
--max-iterations N, -n N Set maximum iterations (default: dynamic)
--no-retry Disable auto-retry on agent crashes
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ When `--max-iterations` is not specified, Chief calculates a dynamic limit based

## Agent

Chief can use **Claude Code** (default), **Codex CLI**, **OpenCode CLI**, or **Cursor CLI** as the agent. Choose via:
Chief can use **Claude Code** (default), **Codex CLI**, **OpenCode CLI**, **Cursor CLI**, or **Gemini CLI** as the agent. Choose via:

- **Config:** `agent.provider: opencode` and optionally `agent.cliPath: /path/to/opencode` in `.chief/config.yaml`
- **Environment:** `CHIEF_AGENT=opencode`, `CHIEF_AGENT_PATH=/path/to/opencode`
Expand Down
91 changes: 91 additions & 0 deletions internal/agent/gemini.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package agent

import (
"context"
"encoding/json"
"os/exec"
"strings"

"github.com/minicodemonkey/chief/internal/loop"
)

// GeminiProvider implements loop.Provider for the Gemini CLI.
type GeminiProvider struct {
cliPath string
}

// NewGeminiProvider returns a Provider for the Gemini CLI.
// If cliPath is empty, "gemini" is used.
func NewGeminiProvider(cliPath string) *GeminiProvider {
if cliPath == "" {
cliPath = "gemini"
}
return &GeminiProvider{cliPath: cliPath}
}

// Name implements loop.Provider.
func (p *GeminiProvider) Name() string { return "Gemini" }

// CLIPath implements loop.Provider.
func (p *GeminiProvider) CLIPath() string { return p.cliPath }

// LoopCommand implements loop.Provider.
// Runs Gemini in non-interactive (headless) mode with streaming JSON output
// and YOLO approval mode so all tool calls are auto-approved.
func (p *GeminiProvider) LoopCommand(ctx context.Context, prompt, workDir string) *exec.Cmd {
cmd := exec.CommandContext(ctx, p.cliPath,
"-p", prompt,
"--output-format", "stream-json",
"--yolo",
)
cmd.Dir = workDir
return cmd
}

// InteractiveCommand implements loop.Provider.
func (p *GeminiProvider) InteractiveCommand(workDir, prompt string) *exec.Cmd {
cmd := exec.Command(p.cliPath, prompt)
cmd.Dir = workDir
return cmd
}

// ParseLine implements loop.Provider.
func (p *GeminiProvider) ParseLine(line string) *loop.Event {
return loop.ParseLineGemini(line)
}

// LogFileName implements loop.Provider.
func (p *GeminiProvider) LogFileName() string { return "gemini.log" }

// geminiAssistantMessage is used by CleanOutput to extract assistant text deltas.
type geminiAssistantMessage struct {
Type string `json:"type"`
Role string `json:"role"`
Content string `json:"content"`
}

// CleanOutput concatenates all assistant message delta chunks from Gemini's
// stream-json NDJSON output and returns the full assistant response.
// Falls back to the raw output if no assistant messages are found.
func (p *GeminiProvider) CleanOutput(output string) string {
output = strings.TrimSpace(output)
if output == "" {
return output
}

var sb strings.Builder
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var msg geminiAssistantMessage
if json.Unmarshal([]byte(line), &msg) == nil && msg.Type == "message" && msg.Role == "assistant" && msg.Content != "" {
sb.WriteString(msg.Content)
}
}
if sb.Len() > 0 {
return sb.String()
}
return output
}
159 changes: 159 additions & 0 deletions internal/agent/gemini_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
package agent

import (
"context"
"strings"
"testing"

"github.com/minicodemonkey/chief/internal/loop"
)

func TestGeminiProvider_Name(t *testing.T) {
p := NewGeminiProvider("")
if p.Name() != "Gemini" {
t.Errorf("Name() = %q, want Gemini", p.Name())
}
}

func TestGeminiProvider_CLIPath(t *testing.T) {
p := NewGeminiProvider("")
if p.CLIPath() != "gemini" {
t.Errorf("CLIPath() empty arg = %q, want gemini", p.CLIPath())
}
p2 := NewGeminiProvider("/usr/local/bin/gemini")
if p2.CLIPath() != "/usr/local/bin/gemini" {
t.Errorf("CLIPath() custom = %q, want /usr/local/bin/gemini", p2.CLIPath())
}
}

func TestGeminiProvider_LogFileName(t *testing.T) {
p := NewGeminiProvider("")
if p.LogFileName() != "gemini.log" {
t.Errorf("LogFileName() = %q, want gemini.log", p.LogFileName())
}
}

func TestGeminiProvider_LoopCommand(t *testing.T) {
ctx := context.Background()
p := NewGeminiProvider("/bin/gemini")
cmd := p.LoopCommand(ctx, "hello world", "/work/dir")

if cmd.Path != "/bin/gemini" {
t.Errorf("LoopCommand Path = %q, want /bin/gemini", cmd.Path)
}
wantArgs := []string{"/bin/gemini", "-p", "hello world", "--output-format", "stream-json", "--yolo"}
if len(cmd.Args) != len(wantArgs) {
t.Fatalf("LoopCommand Args len = %d, want %d: %v", len(cmd.Args), len(wantArgs), cmd.Args)
}
for i, w := range wantArgs {
if cmd.Args[i] != w {
t.Errorf("LoopCommand Args[%d] = %q, want %q", i, cmd.Args[i], w)
}
}
if cmd.Dir != "/work/dir" {
t.Errorf("LoopCommand Dir = %q, want /work/dir", cmd.Dir)
}
}

func TestGeminiProvider_InteractiveCommand(t *testing.T) {
p := NewGeminiProvider("/bin/gemini")
cmd := p.InteractiveCommand("/work", "my prompt")
if cmd.Dir != "/work" {
t.Errorf("InteractiveCommand Dir = %q, want /work", cmd.Dir)
}
if len(cmd.Args) != 2 || cmd.Args[0] != "/bin/gemini" || cmd.Args[1] != "my prompt" {
t.Errorf("InteractiveCommand Args = %v, want [/bin/gemini my prompt]", cmd.Args)
}
}

func TestGeminiProvider_ParseLine(t *testing.T) {
p := NewGeminiProvider("")

// init event -> EventIterationStart
e := p.ParseLine(`{"type":"init","timestamp":"2025-01-01T00:00:00.000Z","session_id":"abc","model":"gemini-2.5-pro"}`)
if e == nil {
t.Fatal("ParseLine(init) returned nil")
}
if e.Type != loop.EventIterationStart {
t.Errorf("ParseLine(init) Type = %v, want EventIterationStart", e.Type)
}

// assistant message -> EventAssistantText
e = p.ParseLine(`{"type":"message","timestamp":"2025-01-01T00:00:00.000Z","role":"assistant","content":"Hello!","delta":true}`)
if e == nil {
t.Fatal("ParseLine(assistant message) returned nil")
}
if e.Type != loop.EventAssistantText {
t.Errorf("ParseLine(assistant message) Type = %v, want EventAssistantText", e.Type)
}
if e.Text != "Hello!" {
t.Errorf("ParseLine(assistant message) Text = %q, want Hello!", e.Text)
}

// chief-done tag -> EventStoryDone
e = p.ParseLine(`{"type":"message","timestamp":"2025-01-01T00:00:00.000Z","role":"assistant","content":"Done <chief-done/>","delta":true}`)
if e == nil {
t.Fatal("ParseLine(chief-done) returned nil")
}
if e.Type != loop.EventStoryDone {
t.Errorf("ParseLine(chief-done) Type = %v, want EventStoryDone", e.Type)
}
}

func TestGeminiProvider_CleanOutput_singleChunk(t *testing.T) {
p := NewGeminiProvider("")
input := `{"type":"init","session_id":"s1","model":"gemini-2.5-pro"}
{"type":"message","role":"assistant","content":"Hello from Gemini!","delta":true}
{"type":"result","status":"success","stats":{}}`
got := p.CleanOutput(input)
if got != "Hello from Gemini!" {
t.Errorf("CleanOutput(single chunk) = %q, want %q", got, "Hello from Gemini!")
}
}

func TestGeminiProvider_CleanOutput_multipleChunks(t *testing.T) {
p := NewGeminiProvider("")
input := `{"type":"init","session_id":"s1","model":"gemini-2.5-pro"}
{"type":"message","role":"assistant","content":"Hello ","delta":true}
{"type":"message","role":"assistant","content":"from ","delta":true}
{"type":"message","role":"assistant","content":"Gemini!","delta":true}
{"type":"result","status":"success","stats":{}}`
got := p.CleanOutput(input)
want := "Hello from Gemini!"
if got != want {
t.Errorf("CleanOutput(multiple chunks) = %q, want %q", got, want)
}
}

func TestGeminiProvider_CleanOutput_noAssistantMessages(t *testing.T) {
p := NewGeminiProvider("")
// When there are no assistant messages, fall back to the raw output.
input := `{"type":"result","status":"success","stats":{}}`
got := p.CleanOutput(input)
if got != input {
t.Errorf("CleanOutput(no assistant) = %q, want raw output %q", got, input)
}
}

func TestGeminiProvider_CleanOutput_empty(t *testing.T) {
p := NewGeminiProvider("")
if p.CleanOutput("") != "" {
t.Errorf("CleanOutput('') should return empty string")
}
if p.CleanOutput(" ") != "" {
t.Errorf("CleanOutput(' ') should return empty string")
}
}

func TestGeminiProvider_CleanOutput_skipsUserMessages(t *testing.T) {
p := NewGeminiProvider("")
input := `{"type":"message","role":"user","content":"do something","delta":false}
{"type":"message","role":"assistant","content":"Sure!","delta":true}`
got := p.CleanOutput(input)
if got != "Sure!" {
t.Errorf("CleanOutput(skips user) = %q, want %q", got, "Sure!")
}
if strings.Contains(got, "do something") {
t.Errorf("CleanOutput should not include user messages in output")
}
}
4 changes: 3 additions & 1 deletion internal/agent/resolve.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,10 @@ func Resolve(flagAgent, flagPath string, cfg *config.Config) (loop.Provider, err
return NewOpenCodeProvider(cliPath), nil
case "cursor":
return NewCursorProvider(cliPath), nil
case "gemini":
return NewGeminiProvider(cliPath), nil
default:
return nil, fmt.Errorf("unknown agent provider %q: expected \"claude\", \"codex\", \"opencode\", or \"cursor\"", providerName)
return nil, fmt.Errorf("unknown agent provider %q: expected \"claude\", \"codex\", \"opencode\", \"cursor\", or \"gemini\"", providerName)
}
}

Expand Down
31 changes: 31 additions & 0 deletions internal/agent/resolve_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,34 @@ func TestResolve_cursor(t *testing.T) {
}
}

func TestResolve_gemini(t *testing.T) {
got := mustResolve(t, "gemini", "", nil)
if got.Name() != "Gemini" {
t.Errorf("Resolve(gemini) name = %q, want Gemini", got.Name())
}
if got.CLIPath() != "gemini" {
t.Errorf("Resolve(gemini) CLIPath = %q, want gemini", got.CLIPath())
}

// Custom path
got = mustResolve(t, "gemini", "/usr/local/bin/gemini", nil)
if got.CLIPath() != "/usr/local/bin/gemini" {
t.Errorf("Resolve(gemini, /usr/local/bin/gemini) CLIPath = %q, want /usr/local/bin/gemini", got.CLIPath())
}

// From config
cfg := &config.Config{}
cfg.Agent.Provider = "gemini"
cfg.Agent.CLIPath = "/opt/gemini"
got = mustResolve(t, "", "", cfg)
if got.Name() != "Gemini" {
t.Errorf("Resolve(_, _, config gemini) name = %q, want Gemini", got.Name())
}
if got.CLIPath() != "/opt/gemini" {
t.Errorf("Resolve(_, _, config gemini) CLIPath = %q, want /opt/gemini", got.CLIPath())
}
}

func TestResolve_unknownProvider(t *testing.T) {
_, err := Resolve("typo", "", nil)
if err == nil {
Expand All @@ -158,6 +186,9 @@ func TestResolve_unknownProvider(t *testing.T) {
if !strings.Contains(err.Error(), "typo") {
t.Errorf("error should mention the bad provider name: %v", err)
}
if !strings.Contains(err.Error(), "gemini") {
t.Errorf("error should mention gemini as a valid option: %v", err)
}
}

func TestCheckInstalled_notFound(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type Config struct {

// AgentConfig holds agent CLI settings (Claude, Codex, OpenCode, or Cursor).
type AgentConfig struct {
Provider string `yaml:"provider"` // "claude" (default) | "codex" | "opencode" | "cursor"
Provider string `yaml:"provider"` // "claude" (default) | "codex" | "opencode" | "cursor" | "gemini"
CLIPath string `yaml:"cliPath"` // optional custom path to CLI binary
}

Expand Down
Loading