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
2 changes: 1 addition & 1 deletion cmd/roborev/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func main() {
rootCmd := &cobra.Command{
Use: "roborev",
Short: "Automatic code review for git commits",
Long: "roborev automatically reviews git commits using AI agents (Codex, Claude Code, Gemini, Copilot, OpenCode, Cursor)",
Long: "roborev automatically reviews git commits using AI agents (Codex, Claude Code, Gemini, Copilot, OpenCode, Cursor, Pi)",
}

rootCmd.PersistentFlags().StringVar(&serverAddr, "server", "http://127.0.0.1:7373", "daemon server address")
Expand Down
16 changes: 13 additions & 3 deletions cmd/roborev/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func reviewCmd() *cobra.Command {
baseBranch string
since string
local bool
provider string
)

cmd := &cobra.Command{
Expand Down Expand Up @@ -250,7 +251,7 @@ Examples:

// Handle --local mode: run agent directly without daemon
if local {
return runLocalReview(cmd, root, gitRef, diffContent, agent, model, reasoning, reviewType, quiet)
return runLocalReview(cmd, root, gitRef, diffContent, agent, model, provider, reasoning, reviewType, quiet)
}

// Build request body
Expand All @@ -260,6 +261,7 @@ Examples:
Branch: branchName,
Agent: agent,
Model: model,
Provider: provider,
Reasoning: reasoning,
ReviewType: reviewType,
DiffContent: diffContent,
Expand Down Expand Up @@ -322,7 +324,7 @@ Examples:

cmd.Flags().StringVar(&repoPath, "repo", "", "path to git repository (default: current directory)")
cmd.Flags().StringVar(&sha, "sha", "HEAD", "commit SHA to review (used when no positional args)")
cmd.Flags().StringVar(&agent, "agent", "", "agent to use (codex, claude-code, gemini, copilot, opencode, cursor, kilo)")
cmd.Flags().StringVar(&agent, "agent", "", "agent to use (codex, claude-code, gemini, copilot, opencode, cursor, kilo, droid, pi)")
cmd.Flags().StringVar(&model, "model", "", "model for agent (format varies: opencode uses provider/model, others use model name)")
cmd.Flags().StringVar(&reasoning, "reasoning", "", "reasoning level: thorough (default), standard, or fast")
cmd.Flags().BoolVar(&fast, "fast", false, "shorthand for --reasoning fast")
Expand All @@ -335,14 +337,15 @@ Examples:
cmd.Flags().StringVar(&since, "since", "", "review commits since this commit (exclusive, like git's .. range)")
cmd.Flags().BoolVar(&local, "local", false, "run review locally without daemon (streams output to console)")
cmd.Flags().StringVar(&reviewType, "type", "", "review type (security, design) — changes system prompt")
cmd.Flags().StringVar(&provider, "provider", "", "provider for pi agent (e.g. anthropic, openai)")
registerAgentCompletion(cmd)
registerReasoningCompletion(cmd)

return cmd
}

// runLocalReview runs a review directly without the daemon
func runLocalReview(cmd *cobra.Command, repoPath, gitRef, diffContent, agentName, model, reasoning, reviewType string, quiet bool) error {
func runLocalReview(cmd *cobra.Command, repoPath, gitRef, diffContent, agentName, model, provider, reasoning, reviewType string, quiet bool) error {
// Load config
cfg, err := config.LoadGlobal()
if err != nil {
Expand Down Expand Up @@ -377,6 +380,13 @@ func runLocalReview(cmd *cobra.Command, repoPath, gitRef, diffContent, agentName
reasoningLevel := agent.ParseReasoningLevel(reasoning)
a = a.WithReasoning(reasoningLevel).WithModel(model)

// Configure provider for pi agent
if provider != "" {
if pa, ok := a.(*agent.PiAgent); ok {
a = pa.WithProvider(provider)
}
}

// Use consistent output writer, respecting --quiet
var out = cmd.OutOrStdout()
if quiet {
Expand Down
45 changes: 26 additions & 19 deletions internal/agent/acp.go
Original file line number Diff line number Diff line change
Expand Up @@ -1508,39 +1508,46 @@ func configuredACPAgent(cfg *config.Config) *ACPAgent {
// GetAvailableWithConfig resolves an available agent while honoring runtime ACP config.
// It treats cfg.ACP.Name as an alias for "acp" and applies cfg.ACP command/mode/model
// at resolution time instead of package-init time.
// It also applies command overrides for other agents (codex, claude, cursor, pi).
func GetAvailableWithConfig(preferred string, cfg *config.Config) (Agent, error) {
preferred = resolveAlias(strings.TrimSpace(preferred))

if isConfiguredACPAgentName(preferred, cfg) {
if cfg != nil && cfg.ACP != nil && isConfiguredACPAgentName(preferred, cfg) {
acpAgent := configuredACPAgent(cfg)
if _, err := exec.LookPath(acpAgent.CommandName()); err == nil {
return acpAgent, nil
}
// ACP requested with an invalid configured command. Try canonical ACP next.
if canonicalACP, err := Get(defaultACPName); err == nil {
if commandAgent, ok := canonicalACP.(CommandAgent); !ok {
return canonicalACP, nil
} else if _, err := exec.LookPath(commandAgent.CommandName()); err == nil {
return canonicalACP, nil
}
}

// Finally fall back to normal auto-selection.
return GetAvailable("")
}

resolved, err := GetAvailable(preferred)
// Resolve the agent first
a, err := GetAvailable(preferred)
if err != nil {
return nil, err
}
if resolved.Name() == defaultACPName {
configured := configuredACPAgent(cfg)
if _, err := exec.LookPath(configured.CommandName()); err == nil {
return configured, nil

// Apply command overrides from config
if cfg != nil {
switch agent := a.(type) {
case *CodexAgent:
if cfg.CodexCmd != "" {
agent.Command = cfg.CodexCmd
}
case *ClaudeAgent:
if cfg.ClaudeCodeCmd != "" {
agent.Command = cfg.ClaudeCodeCmd
}
case *CursorAgent:
if cfg.CursorCmd != "" {
agent.Command = cfg.CursorCmd
}
case *PiAgent:
if cfg.PiCmd != "" {
agent.Command = cfg.PiCmd
}
}
return resolved, nil
}
return resolved, nil

return a, nil
}

func applyACPAgentConfigOverride(cfg *config.ACPAgentConfig, override *config.ACPAgentConfig) {
Expand Down
6 changes: 3 additions & 3 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -177,8 +177,8 @@ func GetAvailable(preferred string) (Agent, error) {
return Get(preferred)
}

// Fallback order: codex, claude-code, gemini, copilot, opencode, cursor, kilo, droid
fallbacks := []string{"codex", "claude-code", "gemini", "copilot", "opencode", "cursor", "kilo", "droid"}
// Fallback order: codex, claude-code, gemini, copilot, opencode, cursor, kilo, droid, pi
fallbacks := []string{"codex", "claude-code", "gemini", "copilot", "opencode", "cursor", "kilo", "droid", "pi"}
for _, name := range fallbacks {
if name != preferred && IsAvailable(name) {
return Get(name)
Expand All @@ -194,7 +194,7 @@ func GetAvailable(preferred string) (Agent, error) {
}

if len(available) == 0 {
return nil, fmt.Errorf("no agents available (install one of: codex, claude-code, gemini, copilot, opencode, cursor, kilo, droid)\nYou may need to run 'roborev daemon restart' from a shell that has access to your agents")
return nil, fmt.Errorf("no agents available (install one of: codex, claude-code, gemini, copilot, opencode, cursor, kilo, droid, pi)\nYou may need to run 'roborev daemon restart' from a shell that has access to your agents")
}

return Get(available[0])
Expand Down
192 changes: 192 additions & 0 deletions internal/agent/pi.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package agent

import (
"bytes"
"context"
"fmt"
"io"
"os"
"os/exec"
"strings"
)

// PiAgent runs code reviews using the pi CLI
type PiAgent struct {
Command string // The pi command to run (default: "pi")
Model string // Model to use (provider/model format or just model)
Provider string // Explicit provider (optional)
Reasoning ReasoningLevel // Reasoning level
Agentic bool // Agentic mode
}

// NewPiAgent creates a new pi agent
func NewPiAgent(command string) *PiAgent {
if command == "" {
command = "pi"
}
return &PiAgent{Command: command, Reasoning: ReasoningStandard}
}

func (a *PiAgent) Name() string {
return "pi"
}

// WithReasoning returns a copy of the agent configured with the specified reasoning level.
func (a *PiAgent) WithReasoning(level ReasoningLevel) Agent {
return &PiAgent{
Command: a.Command,
Model: a.Model,
Provider: a.Provider,
Reasoning: level,
Agentic: a.Agentic,
}
}

// WithAgentic returns a copy of the agent configured for agentic mode.
func (a *PiAgent) WithAgentic(agentic bool) Agent {
return &PiAgent{
Command: a.Command,
Model: a.Model,
Provider: a.Provider,
Reasoning: a.Reasoning,
Agentic: agentic,
}
}

// WithModel returns a copy of the agent configured to use the specified model.
func (a *PiAgent) WithModel(model string) Agent {
if model == "" {
return a
}
return &PiAgent{
Command: a.Command,
Model: model,
Provider: a.Provider,
Reasoning: a.Reasoning,
Agentic: a.Agentic,
}
}

// WithProvider returns a copy of the agent configured to use the specified provider.
func (a *PiAgent) WithProvider(provider string) Agent {
if provider == "" {
return a
}
return &PiAgent{
Command: a.Command,
Model: a.Model,
Provider: provider,
Reasoning: a.Reasoning,
Agentic: a.Agentic,
}
}

func (a *PiAgent) CommandName() string {
return a.Command
}

func (a *PiAgent) CommandLine() string {
args := []string{"-p"}
if a.Provider != "" {
args = append(args, "--provider", a.Provider)
}
if a.Model != "" {
args = append(args, "--model", a.Model)
}
if level := a.thinkingLevel(); level != "" {
args = append(args, "--thinking", level)
}
return a.Command + " " + strings.Join(args, " ")
}

func (a *PiAgent) thinkingLevel() string {
switch a.Reasoning {
case ReasoningThorough:
return "high"
case ReasoningFast:
return "low"
default: // Standard
return "medium"
}
}

func (a *PiAgent) Review(
ctx context.Context,
repoPath, commitSHA, prompt string,
output io.Writer,
) (string, error) {
// Write prompt to a temporary file to avoid command line length limits
// and to properly handle special characters.
tmpDir := os.TempDir()
tmpFile, err := os.CreateTemp(tmpDir, "roborev-pi-prompt-*.md")
if err != nil {
return "", fmt.Errorf("create temp prompt file: %w", err)
}
defer os.Remove(tmpFile.Name())

if _, err := tmpFile.WriteString(prompt); err != nil {
tmpFile.Close()
return "", fmt.Errorf("write prompt to temp file: %w", err)
}
tmpFile.Close()

args := []string{"-p"} // Print response and exit
if a.Provider != "" {
args = append(args, "--provider", a.Provider)
}
if a.Model != "" {
args = append(args, "--model", a.Model)
}
if level := a.thinkingLevel(); level != "" {
args = append(args, "--thinking", level)
}

// Add the prompt file as an input argument (prefixed with @)
// Pi treats @files as context/input.
// Since the prompt contains the instructions, we pass it as a file.
// But pi might expect instructions as text arguments.
// The docs say: pi [options] [@files...] [messages...]
// If we only provide @file, pi reads it. Does it treat it as a user message or context?
// Usually @file is context.
// We want the prompt to be the "message".
// But if the prompt is huge, we can't pass it as an argument.
// If we pass @file, pi loads the file.
// We might need to add a small trigger message like "Follow the instructions in the attached file."
args = append(args, "@"+tmpFile.Name(), "Please follow the instructions in the attached file to review the code.")

cmd := exec.CommandContext(ctx, a.Command, args...)
cmd.Dir = repoPath

// Capture stdout for the result
var stdoutBuf bytes.Buffer
var stderrBuf bytes.Buffer

// Stream stdout to output writer if provided
if output != nil {
sw := newSyncWriter(output)
cmd.Stdout = io.MultiWriter(&stdoutBuf, sw)
cmd.Stderr = io.MultiWriter(&stderrBuf, sw)
} else {
cmd.Stdout = &stdoutBuf
cmd.Stderr = &stderrBuf
}

if err := cmd.Run(); err != nil {
return "", fmt.Errorf("pi failed: %w\nstderr: %s", err, stderrBuf.String())
}

result := stdoutBuf.String()
if result == "" {
// Fallback to stderr if stdout is empty (though -p should print to stdout)
if stderrBuf.Len() > 0 {
return stderrBuf.String(), nil
}
return "No review output generated", nil
}

return result, nil
}

func init() {
Register(NewPiAgent(""))
}
2 changes: 2 additions & 0 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ type Config struct {
CodexCmd string `toml:"codex_cmd"`
ClaudeCodeCmd string `toml:"claude_code_cmd"`
CursorCmd string `toml:"cursor_cmd"`
PiCmd string `toml:"pi_cmd"`

// API keys (optional - agents use subscription auth by default)
AnthropicAPIKey string `toml:"anthropic_api_key" sensitive:"true"`
Expand Down Expand Up @@ -477,6 +478,7 @@ func DefaultConfig() *Config {
CodexCmd: "codex",
ClaudeCodeCmd: "claude",
CursorCmd: "agent",
PiCmd: "pi",
}
}

Expand Down
Loading
Loading