Skip to content
Merged
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
9 changes: 5 additions & 4 deletions cmd/roborev/ghaction.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,14 @@ After generating the workflow, add repository secrets ` +
// List required secrets per agent
infos := ghaction.AgentSecrets(cfg.Agents)
for i, info := range infos {
if info.Name == "opencode" {
if info.Name == "opencode" ||
info.Name == "kilo" {
fmt.Printf(
" %d. Add a repository secret "+
"named %q (default for "+
"opencode; change if using "+
"%s; change if using "+
"a different provider)\n",
i+1, info.SecretName)
i+1, info.SecretName, info.Name)
} else {
fmt.Printf(
" %d. Add a repository secret "+
Expand All @@ -97,7 +98,7 @@ After generating the workflow, add repository secrets ` +
cmd.Flags().StringVar(&agentFlag, "agent", "",
"agents to use, comma-separated "+
"(codex, claude-code, gemini, copilot, "+
"opencode, cursor, droid)")
"opencode, cursor, kiro, kilo, droid)")
cmd.Flags().StringVar(&outputPath, "output", "",
"output path for workflow file "+
"(default: .github/workflows/roborev.yml)")
Expand Down
30 changes: 29 additions & 1 deletion cmd/roborev/ghaction_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ func TestGhActionCmd(t *testing.T) {
expectError bool
errorContains string
expectedContains []string
notContains []string
}{
{
name: "default flags",
Expand Down Expand Up @@ -70,6 +71,27 @@ func TestGhActionCmd(t *testing.T) {
flags: []string{"--agent", "codex"},
expectedContains: []string{"OPENAI_API_KEY"},
},
{
name: "kilo gets multi-provider guidance",
flags: []string{"--agent", "kilo"},
expectedContains: []string{
"ANTHROPIC_API_KEY",
"@kilocode/cli@latest",
"different model provider",
"default for kilo",
},
},
{
name: "kiro has no secret in env block",
flags: []string{"--agent", "kiro"},
expectedContains: []string{
"kiro.dev",
},
notContains: []string{
"OPENAI_API_KEY:",
"AWS_ACCESS_KEY_ID:",
},
},
{
name: "infers agents from repo CI config",
repoConfig: "[ci]\nagents = " +
Expand Down Expand Up @@ -120,7 +142,8 @@ func TestGhActionCmd(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}

if len(tt.expectedContains) > 0 {
if len(tt.expectedContains) > 0 ||
len(tt.notContains) > 0 {
contentBytes, err := os.ReadFile(outPath)
if err != nil {
t.Fatalf("failed to read generated file: %v", err)
Expand All @@ -131,6 +154,11 @@ func TestGhActionCmd(t *testing.T) {
t.Errorf("generated file missing expected content: %q", expected)
}
}
for _, bad := range tt.notContains {
if strings.Contains(content, bad) {
t.Errorf("generated file should not contain: %q", bad)
}
}
}
})
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/roborev/init_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ func initCmd() *cobra.Command {
},
}

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

Expand Down
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, Kiro)",
}

rootCmd.PersistentFlags().StringVar(&serverAddr, "server", "http://127.0.0.1:7373", "daemon server address")
Expand Down
2 changes: 1 addition & 1 deletion cmd/roborev/review.go
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,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, kiro, kilo)")
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 @@ -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, kiro, kilo, droid
fallbacks := []string{"codex", "claude-code", "gemini", "copilot", "opencode", "cursor", "kiro", "kilo", "droid"}
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, kiro, kilo, 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 @@ -16,7 +16,7 @@ import (
)

// expectedAgents is the single source of truth for registered agent names.
var expectedAgents = []string{"codex", "claude-code", "gemini", "copilot", "opencode", "cursor", "kilo", "droid", "test"}
var expectedAgents = []string{"codex", "claude-code", "gemini", "copilot", "opencode", "cursor", "kiro", "kilo", "droid", "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
191 changes: 191 additions & 0 deletions internal/agent/kiro.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package agent

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

// maxPromptArgLen is a conservative limit for passing prompts as
// CLI arguments. macOS ARG_MAX is ~1 MB; we leave headroom for
// the command name, flags, and environment.
const maxPromptArgLen = 512 * 1024

// stripKiroOutput removes Kiro's UI chrome (logo, tip box, model line, timing footer)
// and terminal control sequences, returning only the review text.
func stripKiroOutput(raw string) string {
text, _ := stripKiroReview(raw)
return text
}

// stripKiroReview strips Kiro chrome and returns the cleaned text
// plus a bool indicating whether a "> " review marker was found.
// When no marker is found the full ANSI-stripped text is returned
// (hasMarker == false), which may be non-review noise.
func stripKiroReview(raw string) (string, bool) {
s := stripTerminalControls(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 for the start marker.
lines := strings.Split(s, "\n")
limit := min(30, 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), false
}

// Strip the prompt marker from the first content line.
// A bare ">" (no trailing content) is skipped entirely.
if lines[start] == ">" {
start++
if start >= len(lines) {
return "", true
}
} else {
lines[start] = strings.TrimPrefix(lines[start], "> ")
}

// Drop the timing footer ("▸ Time: Xs") and anything after it.
// Trim trailing blank lines first so they don't push the real
// footer outside the scan window, then scan the last 5 non-blank
// lines to avoid truncating review content that happens to
// contain "▸ Time:" in a code snippet.
end := len(lines)
for end > start && strings.TrimSpace(lines[end-1]) == "" {
end--
}
scanFrom := max(start, end-5)
for i := scanFrom; i < end; i++ {
if strings.HasPrefix(strings.TrimSpace(lines[i]), "▸ Time:") {
end = i
break
}
}

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

// 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) {
if len(prompt) > maxPromptArgLen {
return "", fmt.Errorf(
"prompt too large for kiro-cli argv (%d bytes, max %d)",
len(prompt), maxPromptArgLen,
)
}

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 support 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. Capture 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(),
)
}

// Prefer the stream that contains a "> " review marker.
// - stdout with marker and content → use stdout
// - stdout empty or marker-only → try stderr
// - stdout has content but no marker → use stderr only
// if stderr has a marker (otherwise keep stdout)
result, stdoutMarker := stripKiroReview(stdout.String())
if !stdoutMarker || len(result) == 0 {
alt, stderrMarker := stripKiroReview(stderr.String())
if len(alt) > 0 && (len(result) == 0 || stderrMarker) {
result = alt
}
}
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
Loading