diff --git a/cmd/roborev/main.go b/cmd/roborev/main.go index 4b6b68eb..eae41c4a 100644 --- a/cmd/roborev/main.go +++ b/cmd/roborev/main.go @@ -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 @@ -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") diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 303bdfd3..cdcef364 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -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) @@ -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]) diff --git a/internal/agent/agent_test_helpers.go b/internal/agent/agent_test_helpers.go index c8162380..7ae35f0e 100644 --- a/internal/agent/agent_test_helpers.go +++ b/internal/agent/agent_test_helpers.go @@ -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. diff --git a/internal/agent/kiro.go b/internal/agent/kiro.go new file mode 100644 index 00000000..79e1a4e6 --- /dev/null +++ b/internal/agent/kiro.go @@ -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, " ") + " " +} + +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] + // 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("")) +} diff --git a/internal/agent/kiro_test.go b/internal/agent/kiro_test.go new file mode 100644 index 00000000..ebcb1e85 --- /dev/null +++ b/internal/agent/kiro_test.go @@ -0,0 +1,278 @@ +package agent + +import ( + "context" + "os" + "strings" + "testing" +) + +func TestStripKiroOutput(t *testing.T) { + // Simulated kiro-cli stdout with ANSI codes, logo, tip box, model line, and timing footer. + raw := "\x1b[38;5;141m⠀⠀logo⠀⠀\x1b[0m\n" + + "\x1b[38;5;244m╭─── Did you know? ───╮\x1b[0m\n" + + "\x1b[38;5;244m│ tip text │\x1b[0m\n" + + "\x1b[38;5;244m╰─────────────────────╯\x1b[0m\n" + + "\x1b[38;5;244mModel: auto\x1b[0m\n" + + "\n" + + "\x1b[38;5;141m> \x1b[0m\x1b[1m## Summary\x1b[0m\n" + + "This commit does something.\n" + + "\n" + + "## Issues Found\n" + + "\n" + + " \u25b8 Time: 21s\n" + + got := stripKiroOutput(raw) + + if strings.Contains(got, "\x1b[") { + t.Error("result still contains ANSI escape codes") + } + if !strings.Contains(got, "## Summary") { + t.Errorf("expected '## Summary' in result, got: %q", got) + } + if !strings.Contains(got, "## Issues Found") { + t.Errorf("expected '## Issues Found' in result, got: %q", got) + } + if strings.Contains(got, "Did you know") { + t.Error("result should not contain tip box text") + } + if strings.Contains(got, "Model:") { + t.Error("result should not contain model line") + } + if strings.Contains(got, "Time:") { + t.Error("result should not contain timing footer") + } + if strings.HasPrefix(got, "> ") { + t.Error("result should not have leading '> ' prefix") + } +} + +func TestStripKiroOutputNoMarker(t *testing.T) { + // If there's no "> " marker in the first 30 lines, return ANSI-stripped text as-is. + raw := "\x1b[1msome output without marker\x1b[0m\n" + got := stripKiroOutput(raw) + if got != "some output without marker" { + t.Errorf("unexpected result: %q", got) + } +} + +func TestStripKiroOutputBlockquoteNotStripped(t *testing.T) { + // A "> " blockquote deep in review content should not be treated as the start marker. + // Build output where "> " only appears after line 30. + var lines []string + for i := 0; i < 31; i++ { + lines = append(lines, "chrome line") + } + lines = append(lines, "> this is a blockquote in review content") + lines = append(lines, "more content") + raw := strings.Join(lines, "\n") + + got := stripKiroOutput(raw) + if !strings.Contains(got, "> this is a blockquote") { + t.Errorf("blockquote should be preserved in output, got: %q", got) + } +} + +func TestKiroBuildArgs(t *testing.T) { + a := NewKiroAgent("kiro-cli") + + // Non-agentic mode: no --trust-all-tools + args := a.buildArgs(false) + assertContainsArg(t, args, "chat") + assertContainsArg(t, args, "--no-interactive") + assertNotContainsArg(t, args, "--trust-all-tools") + + // Agentic mode: adds --trust-all-tools + args = a.buildArgs(true) + assertContainsArg(t, args, "chat") + assertContainsArg(t, args, "--no-interactive") + assertContainsArg(t, args, "--trust-all-tools") +} + +func TestKiroName(t *testing.T) { + a := NewKiroAgent("") + if a.Name() != "kiro" { + t.Fatalf("expected name 'kiro', got %s", a.Name()) + } + if a.CommandName() != "kiro-cli" { + t.Fatalf("expected command name 'kiro-cli', got %s", a.CommandName()) + } +} + +func TestKiroWithAgentic(t *testing.T) { + a := NewKiroAgent("kiro-cli") + if a.Agentic { + t.Fatal("expected non-agentic by default") + } + + a2 := a.WithAgentic(true).(*KiroAgent) + if !a2.Agentic { + t.Fatal("expected agentic after WithAgentic(true)") + } + if a.Agentic { + t.Fatal("original should be unchanged") + } +} + +func TestKiroWithReasoning(t *testing.T) { + a := NewKiroAgent("kiro-cli") + b := a.WithReasoning(ReasoningThorough).(*KiroAgent) + if b.Reasoning != ReasoningThorough { + t.Errorf("expected thorough reasoning, got %q", b.Reasoning) + } + if a.Reasoning != ReasoningStandard { + t.Error("original reasoning should be unchanged") + } +} + +func TestKiroWithModelIsNoop(t *testing.T) { + a := NewKiroAgent("kiro-cli") + b := a.WithModel("some-model") + // kiro-cli has no --model flag; WithModel returns self unchanged + if b != a { + t.Error("WithModel should return the same agent (kiro does not support model selection)") + } +} + +func TestKiroReviewSuccess(t *testing.T) { + skipIfWindows(t) + + output := "LGTM: looks good to me" + script := NewScriptBuilder().AddOutput(output).Build() + cmdPath := writeTempCommand(t, script) + a := NewKiroAgent(cmdPath) + + result, err := a.Review(context.Background(), t.TempDir(), "deadbeef", "review this commit", nil) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if !strings.Contains(result, output) { + t.Fatalf("expected result to contain %q, got %q", output, result) + } +} + +func TestKiroReviewFailure(t *testing.T) { + skipIfWindows(t) + + script := NewScriptBuilder(). + AddRaw(`echo "error: auth failed" >&2`). + AddRaw("exit 1"). + Build() + cmdPath := writeTempCommand(t, script) + a := NewKiroAgent(cmdPath) + + _, err := a.Review(context.Background(), t.TempDir(), "deadbeef", "review this commit", nil) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "kiro failed") { + t.Fatalf("expected 'kiro failed' in error, got %v", err) + } +} + +func TestKiroReviewEmptyOutput(t *testing.T) { + skipIfWindows(t) + + script := NewScriptBuilder().AddRaw("exit 0").Build() + cmdPath := writeTempCommand(t, script) + a := NewKiroAgent(cmdPath) + + result, err := a.Review(context.Background(), t.TempDir(), "deadbeef", "review this commit", nil) + if err != nil { + t.Fatalf("expected no error, got %v", err) + } + if result != "No review output generated" { + t.Fatalf("expected 'No review output generated', got %q", result) + } +} + +func TestKiroPassesPromptAsArg(t *testing.T) { + skipIfWindows(t) + + mock := mockAgentCLI(t, MockCLIOpts{ + CaptureArgs: true, + StdoutLines: []string{"review complete"}, + }) + + a := NewKiroAgent(mock.CmdPath) + prompt := "Review this commit for issues" + _, err := a.Review(context.Background(), t.TempDir(), "HEAD", prompt, nil) + if err != nil { + t.Fatalf("Review failed: %v", err) + } + + args, err := os.ReadFile(mock.ArgsFile) + if err != nil { + t.Fatalf("read args capture: %v", err) + } + if !strings.Contains(string(args), prompt) { + t.Errorf("expected prompt in args, got: %s", string(args)) + } + if !strings.Contains(string(args), "--no-interactive") { + t.Errorf("expected --no-interactive in args, got: %s", string(args)) + } +} + +func TestKiroReviewAgenticMode(t *testing.T) { + skipIfWindows(t) + + mock := mockAgentCLI(t, MockCLIOpts{ + CaptureArgs: true, + StdoutLines: []string{"review complete"}, + }) + + a := NewKiroAgent(mock.CmdPath) + a2 := a.WithAgentic(true).(*KiroAgent) + + _, err := a2.Review(context.Background(), t.TempDir(), "HEAD", "prompt", nil) + if err != nil { + t.Fatalf("Review failed: %v", err) + } + + args, err := os.ReadFile(mock.ArgsFile) + if err != nil { + t.Fatalf("read args capture: %v", err) + } + if !strings.Contains(string(args), "--trust-all-tools") { + t.Errorf("expected --trust-all-tools in args, got: %s", string(args)) + } +} + +func TestKiroReviewAgenticModeFromGlobal(t *testing.T) { + withUnsafeAgents(t, true) + + mock := mockAgentCLI(t, MockCLIOpts{ + CaptureArgs: true, + StdoutLines: []string{"review complete"}, + }) + + a := NewKiroAgent(mock.CmdPath) + _, err := a.Review(context.Background(), t.TempDir(), "HEAD", "prompt", nil) + if err != nil { + t.Fatalf("Review failed: %v", err) + } + + args, err := os.ReadFile(mock.ArgsFile) + if err != nil { + t.Fatalf("read args capture: %v", err) + } + if !strings.Contains(string(args), "--trust-all-tools") { + t.Fatalf("expected --trust-all-tools when global unsafe enabled, got: %s", strings.TrimSpace(string(args))) + } +} + +func TestKiroWithChaining(t *testing.T) { + a := NewKiroAgent("kiro-cli") + b := a.WithReasoning(ReasoningThorough).WithAgentic(true) + kiro := b.(*KiroAgent) + + if kiro.Reasoning != ReasoningThorough { + t.Errorf("expected thorough reasoning, got %q", kiro.Reasoning) + } + if !kiro.Agentic { + t.Error("expected agentic true") + } + if kiro.Command != "kiro-cli" { + t.Errorf("expected command 'kiro-cli', got %q", kiro.Command) + } +}