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
4 changes: 2 additions & 2 deletions cmd/roborev/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ func initCmd() *cobra.Command {
},
}

cmd.Flags().StringVar(&agent, "agent", "", "default agent (codex, claude-code, gemini, copilot, opencode, cursor)")
cmd.Flags().StringVar(&agent, "agent", "", "default agent (codex, claude-code, gemini, copilot, opencode, cursor, kiro)")
cmd.Flags().BoolVar(&noDaemon, "no-daemon", false, "skip auto-starting daemon (useful with systemd/launchd)")

return cmd
Expand Down Expand Up @@ -1040,7 +1040,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)")
cmd.Flags().StringVar(&agent, "agent", "", "agent to use (codex, claude-code, gemini, copilot, opencode, cursor, kiro)")
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 Down
6 changes: 3 additions & 3 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,8 +166,8 @@ func GetAvailable(preferred string) (Agent, error) {
return Get(preferred)
}

// Fallback order: codex, claude-code, gemini, copilot, opencode, cursor, droid
fallbacks := []string{"codex", "claude-code", "gemini", "copilot", "opencode", "cursor", "droid"}
// Fallback order: codex, claude-code, gemini, copilot, opencode, cursor, kiro, droid
fallbacks := []string{"codex", "claude-code", "gemini", "copilot", "opencode", "cursor", "kiro", "droid"}
for _, name := range fallbacks {
if name != preferred && IsAvailable(name) {
return Get(name)
Expand All @@ -183,7 +183,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, 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, kiro, droid)\nYou may need to run 'roborev daemon restart' from a shell that has access to your agents")
}

return Get(available[0])
Expand Down
2 changes: 1 addition & 1 deletion internal/agent/agent_test_helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (
)

// expectedAgents is the single source of truth for registered agent names.
var expectedAgents = []string{"codex", "claude-code", "gemini", "copilot", "opencode", "cursor", "test"}
var expectedAgents = []string{"codex", "claude-code", "gemini", "copilot", "opencode", "cursor", "kiro", "test"}

// verifyAgentPassesFlag creates a mock command that echoes args, runs the agent's Review method,
// and validates that the output contains the expected flag and value.
Expand Down
146 changes: 146 additions & 0 deletions internal/agent/kiro.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
package agent

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

// ansiEscape matches ANSI/VT100 terminal escape sequences.
var ansiEscape = regexp.MustCompile(`\x1b(?:\[[0-9;?]*[A-Za-z]|[^\[])`)

// stripKiroOutput removes Kiro's UI chrome (logo, tip box, model line, timing footer)
// and ANSI escape codes, returning only the review text.
func stripKiroOutput(raw string) string {
s := ansiEscape.ReplaceAllString(raw, "")

// Kiro prepends a splash screen and tip box before the response.
// The "> " prompt marker appears near the top; limit the search to avoid
// mistaking markdown blockquotes in review content for the start marker.
lines := strings.Split(s, "\n")
limit := 30
if len(lines) < limit {
limit = len(lines)
}
start := -1
for i, line := range lines[:limit] {
if strings.HasPrefix(line, "> ") || line == ">" {
start = i
break
}
}
if start == -1 {
return strings.TrimSpace(s)
}

// Strip the "> " chat-prompt prefix from the first content line.
lines[start] = strings.TrimPrefix(lines[start], "> ")

// Drop the timing footer ("▸ Time: Xs") and anything after it.
end := len(lines)
for i := start; i < len(lines); i++ {
if strings.HasPrefix(strings.TrimSpace(lines[i]), "▸ Time:") {
end = i
break
}
}

return strings.TrimSpace(strings.Join(lines[start:end], "\n"))
}

// KiroAgent runs code reviews using the Kiro CLI (kiro-cli)
type KiroAgent struct {
Command string // The kiro-cli command to run (default: "kiro-cli")
Reasoning ReasoningLevel // Reasoning level (stored; kiro-cli has no reasoning flag)
Agentic bool // Whether agentic mode is enabled (uses --trust-all-tools)
}

// NewKiroAgent creates a new Kiro agent with standard reasoning
func NewKiroAgent(command string) *KiroAgent {
if command == "" {
command = "kiro-cli"
}
return &KiroAgent{Command: command, Reasoning: ReasoningStandard}
}

// WithReasoning returns a copy with the reasoning level stored.
// kiro-cli has no reasoning flag; callers can map reasoning to agent selection instead.
func (a *KiroAgent) WithReasoning(level ReasoningLevel) Agent {
return &KiroAgent{Command: a.Command, Reasoning: level, Agentic: a.Agentic}
}

// WithAgentic returns a copy of the agent configured for agentic mode.
// In agentic mode, --trust-all-tools is passed so kiro can use tools without confirmation.
func (a *KiroAgent) WithAgentic(agentic bool) Agent {
return &KiroAgent{Command: a.Command, Reasoning: a.Reasoning, Agentic: agentic}
}

// WithModel returns the agent unchanged; kiro-cli does not expose a --model CLI flag.
func (a *KiroAgent) WithModel(model string) Agent {
return a
}

func (a *KiroAgent) Name() string {
return "kiro"
}

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

func (a *KiroAgent) buildArgs(agenticMode bool) []string {
args := []string{"chat", "--no-interactive"}
if agenticMode {
args = append(args, "--trust-all-tools")
}
return args
}

func (a *KiroAgent) CommandLine() string {
agenticMode := a.Agentic || AllowUnsafeAgents()
args := a.buildArgs(agenticMode)
return a.Command + " " + strings.Join(args, " ") + " <prompt>"
}

func (a *KiroAgent) Review(ctx context.Context, repoPath, commitSHA, prompt string, output io.Writer) (string, error) {
agenticMode := a.Agentic || AllowUnsafeAgents()

// kiro-cli chat --no-interactive [--trust-all-tools] <prompt>
// The prompt is passed as a positional argument (kiro-cli does not read from stdin).
args := a.buildArgs(agenticMode)
args = append(args, prompt)

cmd := exec.CommandContext(ctx, a.Command, args...)
cmd.Dir = repoPath
cmd.Env = os.Environ()
cmd.WaitDelay = 5 * time.Second

// kiro-cli emits ANSI terminal escape codes that are not suitable for streaming
// through roborev's output writer. Capture stdout/stderr and return stripped text.
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr

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

result := stripKiroOutput(stdout.String())
if len(result) == 0 {
return "No review output generated", nil
}
if sw := newSyncWriter(output); sw != nil {
_, _ = sw.Write([]byte(result + "\n"))
}
return result, nil
}

func init() {
Register(NewKiroAgent(""))
}
Loading