Skip to content

Commit 9aba424

Browse files
wesmmikeaaddclaude
authored
feat: add Kiro agent (kiro-cli) (#419)
## Summary - Add `KiroAgent` implementing the `Agent` interface, invoking `kiro-cli chat --no-interactive` - Strip Kiro UI chrome (ANSI codes, splash screen, tip box, model line, timing footer) from output - Support agentic mode (`--trust-all-tools`), stderr fallback, and 512 KB prompt size guard - Register `kiro` in the agent registry and add it to CLI help text and `gh-action` workflow generation Supersedes #302 — squashed and rebased onto current main with review fixes applied. Original implementation by @mikeaadd. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: mikeaadd <addonisio.m@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 278df12 commit 9aba424

File tree

11 files changed

+817
-15
lines changed

11 files changed

+817
-15
lines changed

cmd/roborev/ghaction.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -65,13 +65,14 @@ After generating the workflow, add repository secrets ` +
6565
// List required secrets per agent
6666
infos := ghaction.AgentSecrets(cfg.Agents)
6767
for i, info := range infos {
68-
if info.Name == "opencode" {
68+
if info.Name == "opencode" ||
69+
info.Name == "kilo" {
6970
fmt.Printf(
7071
" %d. Add a repository secret "+
7172
"named %q (default for "+
72-
"opencode; change if using "+
73+
"%s; change if using "+
7374
"a different provider)\n",
74-
i+1, info.SecretName)
75+
i+1, info.SecretName, info.Name)
7576
} else {
7677
fmt.Printf(
7778
" %d. Add a repository secret "+
@@ -97,7 +98,7 @@ After generating the workflow, add repository secrets ` +
9798
cmd.Flags().StringVar(&agentFlag, "agent", "",
9899
"agents to use, comma-separated "+
99100
"(codex, claude-code, gemini, copilot, "+
100-
"opencode, cursor, droid)")
101+
"opencode, cursor, kiro, kilo, droid)")
101102
cmd.Flags().StringVar(&outputPath, "output", "",
102103
"output path for workflow file "+
103104
"(default: .github/workflows/roborev.yml)")

cmd/roborev/ghaction_test.go

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ func TestGhActionCmd(t *testing.T) {
3636
expectError bool
3737
errorContains string
3838
expectedContains []string
39+
notContains []string
3940
}{
4041
{
4142
name: "default flags",
@@ -70,6 +71,27 @@ func TestGhActionCmd(t *testing.T) {
7071
flags: []string{"--agent", "codex"},
7172
expectedContains: []string{"OPENAI_API_KEY"},
7273
},
74+
{
75+
name: "kilo gets multi-provider guidance",
76+
flags: []string{"--agent", "kilo"},
77+
expectedContains: []string{
78+
"ANTHROPIC_API_KEY",
79+
"@kilocode/cli@latest",
80+
"different model provider",
81+
"default for kilo",
82+
},
83+
},
84+
{
85+
name: "kiro has no secret in env block",
86+
flags: []string{"--agent", "kiro"},
87+
expectedContains: []string{
88+
"kiro.dev",
89+
},
90+
notContains: []string{
91+
"OPENAI_API_KEY:",
92+
"AWS_ACCESS_KEY_ID:",
93+
},
94+
},
7395
{
7496
name: "infers agents from repo CI config",
7597
repoConfig: "[ci]\nagents = " +
@@ -120,7 +142,8 @@ func TestGhActionCmd(t *testing.T) {
120142
t.Fatalf("unexpected error: %v", err)
121143
}
122144

123-
if len(tt.expectedContains) > 0 {
145+
if len(tt.expectedContains) > 0 ||
146+
len(tt.notContains) > 0 {
124147
contentBytes, err := os.ReadFile(outPath)
125148
if err != nil {
126149
t.Fatalf("failed to read generated file: %v", err)
@@ -131,6 +154,11 @@ func TestGhActionCmd(t *testing.T) {
131154
t.Errorf("generated file missing expected content: %q", expected)
132155
}
133156
}
157+
for _, bad := range tt.notContains {
158+
if strings.Contains(content, bad) {
159+
t.Errorf("generated file should not contain: %q", bad)
160+
}
161+
}
134162
}
135163
})
136164
}

cmd/roborev/init_cmd.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ func initCmd() *cobra.Command {
125125
},
126126
}
127127

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

cmd/roborev/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ func main() {
1515
rootCmd := &cobra.Command{
1616
Use: "roborev",
1717
Short: "Automatic code review for git commits",
18-
Long: "roborev automatically reviews git commits using AI agents (Codex, Claude Code, Gemini, Copilot, OpenCode, Cursor)",
18+
Long: "roborev automatically reviews git commits using AI agents (Codex, Claude Code, Gemini, Copilot, OpenCode, Cursor, Kiro)",
1919
}
2020

2121
rootCmd.PersistentFlags().StringVar(&serverAddr, "server", "http://127.0.0.1:7373", "daemon server address")

cmd/roborev/review.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ Examples:
322322

323323
cmd.Flags().StringVar(&repoPath, "repo", "", "path to git repository (default: current directory)")
324324
cmd.Flags().StringVar(&sha, "sha", "HEAD", "commit SHA to review (used when no positional args)")
325-
cmd.Flags().StringVar(&agent, "agent", "", "agent to use (codex, claude-code, gemini, copilot, opencode, cursor, kilo)")
325+
cmd.Flags().StringVar(&agent, "agent", "", "agent to use (codex, claude-code, gemini, copilot, opencode, cursor, kiro, kilo)")
326326
cmd.Flags().StringVar(&model, "model", "", "model for agent (format varies: opencode uses provider/model, others use model name)")
327327
cmd.Flags().StringVar(&reasoning, "reasoning", "", "reasoning level: thorough (default), standard, or fast")
328328
cmd.Flags().BoolVar(&fast, "fast", false, "shorthand for --reasoning fast")

internal/agent/agent.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,8 +177,8 @@ func GetAvailable(preferred string) (Agent, error) {
177177
return Get(preferred)
178178
}
179179

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

196196
if len(available) == 0 {
197-
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")
197+
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")
198198
}
199199

200200
return Get(available[0])

internal/agent/agent_test_helpers.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import (
1616
)
1717

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

2121
// verifyAgentPassesFlag creates a mock command that echoes args, runs the agent's Review method,
2222
// and validates that the output contains the expected flag and value.

internal/agent/kiro.go

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
package agent
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"fmt"
7+
"io"
8+
"os"
9+
"os/exec"
10+
"strings"
11+
"time"
12+
)
13+
14+
// maxPromptArgLen is a conservative limit for passing prompts as
15+
// CLI arguments. macOS ARG_MAX is ~1 MB; we leave headroom for
16+
// the command name, flags, and environment.
17+
const maxPromptArgLen = 512 * 1024
18+
19+
// stripKiroOutput removes Kiro's UI chrome (logo, tip box, model line, timing footer)
20+
// and terminal control sequences, returning only the review text.
21+
func stripKiroOutput(raw string) string {
22+
text, _ := stripKiroReview(raw)
23+
return text
24+
}
25+
26+
// stripKiroReview strips Kiro chrome and returns the cleaned text
27+
// plus a bool indicating whether a "> " review marker was found.
28+
// When no marker is found the full ANSI-stripped text is returned
29+
// (hasMarker == false), which may be non-review noise.
30+
func stripKiroReview(raw string) (string, bool) {
31+
s := stripTerminalControls(raw)
32+
33+
// Kiro prepends a splash screen and tip box before the response.
34+
// The "> " prompt marker appears near the top; limit the search
35+
// to avoid mistaking markdown blockquotes for the start marker.
36+
lines := strings.Split(s, "\n")
37+
limit := min(30, len(lines))
38+
start := -1
39+
for i, line := range lines[:limit] {
40+
if strings.HasPrefix(line, "> ") || line == ">" {
41+
start = i
42+
break
43+
}
44+
}
45+
if start == -1 {
46+
return strings.TrimSpace(s), false
47+
}
48+
49+
// Strip the prompt marker from the first content line.
50+
// A bare ">" (no trailing content) is skipped entirely.
51+
if lines[start] == ">" {
52+
start++
53+
if start >= len(lines) {
54+
return "", true
55+
}
56+
} else {
57+
lines[start] = strings.TrimPrefix(lines[start], "> ")
58+
}
59+
60+
// Drop the timing footer ("▸ Time: Xs") and anything after it.
61+
// Trim trailing blank lines first so they don't push the real
62+
// footer outside the scan window, then scan the last 5 non-blank
63+
// lines to avoid truncating review content that happens to
64+
// contain "▸ Time:" in a code snippet.
65+
end := len(lines)
66+
for end > start && strings.TrimSpace(lines[end-1]) == "" {
67+
end--
68+
}
69+
scanFrom := max(start, end-5)
70+
for i := scanFrom; i < end; i++ {
71+
if strings.HasPrefix(strings.TrimSpace(lines[i]), "▸ Time:") {
72+
end = i
73+
break
74+
}
75+
}
76+
77+
return strings.TrimSpace(strings.Join(lines[start:end], "\n")), true
78+
}
79+
80+
// KiroAgent runs code reviews using the Kiro CLI (kiro-cli)
81+
type KiroAgent struct {
82+
Command string // The kiro-cli command to run (default: "kiro-cli")
83+
Reasoning ReasoningLevel // Reasoning level (stored; kiro-cli has no reasoning flag)
84+
Agentic bool // Whether agentic mode is enabled (uses --trust-all-tools)
85+
}
86+
87+
// NewKiroAgent creates a new Kiro agent with standard reasoning
88+
func NewKiroAgent(command string) *KiroAgent {
89+
if command == "" {
90+
command = "kiro-cli"
91+
}
92+
return &KiroAgent{Command: command, Reasoning: ReasoningStandard}
93+
}
94+
95+
// WithReasoning returns a copy with the reasoning level stored.
96+
// kiro-cli has no reasoning flag; callers can map reasoning to agent selection instead.
97+
func (a *KiroAgent) WithReasoning(level ReasoningLevel) Agent {
98+
return &KiroAgent{Command: a.Command, Reasoning: level, Agentic: a.Agentic}
99+
}
100+
101+
// WithAgentic returns a copy of the agent configured for agentic mode.
102+
// In agentic mode, --trust-all-tools is passed so kiro can use tools without confirmation.
103+
func (a *KiroAgent) WithAgentic(agentic bool) Agent {
104+
return &KiroAgent{Command: a.Command, Reasoning: a.Reasoning, Agentic: agentic}
105+
}
106+
107+
// WithModel returns the agent unchanged; kiro-cli does not expose a --model CLI flag.
108+
func (a *KiroAgent) WithModel(model string) Agent {
109+
return a
110+
}
111+
112+
func (a *KiroAgent) Name() string {
113+
return "kiro"
114+
}
115+
116+
func (a *KiroAgent) CommandName() string {
117+
return a.Command
118+
}
119+
120+
func (a *KiroAgent) buildArgs(agenticMode bool) []string {
121+
args := []string{"chat", "--no-interactive"}
122+
if agenticMode {
123+
args = append(args, "--trust-all-tools")
124+
}
125+
return args
126+
}
127+
128+
func (a *KiroAgent) CommandLine() string {
129+
agenticMode := a.Agentic || AllowUnsafeAgents()
130+
args := a.buildArgs(agenticMode)
131+
return a.Command + " " + strings.Join(args, " ") + " -- <prompt>"
132+
}
133+
134+
func (a *KiroAgent) Review(ctx context.Context, repoPath, commitSHA, prompt string, output io.Writer) (string, error) {
135+
if len(prompt) > maxPromptArgLen {
136+
return "", fmt.Errorf(
137+
"prompt too large for kiro-cli argv (%d bytes, max %d)",
138+
len(prompt), maxPromptArgLen,
139+
)
140+
}
141+
142+
agenticMode := a.Agentic || AllowUnsafeAgents()
143+
144+
// kiro-cli chat --no-interactive [--trust-all-tools] <prompt>
145+
// The prompt is passed as a positional argument
146+
// (kiro-cli does not support stdin).
147+
args := a.buildArgs(agenticMode)
148+
args = append(args, "--", prompt)
149+
150+
cmd := exec.CommandContext(ctx, a.Command, args...)
151+
cmd.Dir = repoPath
152+
cmd.Env = os.Environ()
153+
cmd.WaitDelay = 5 * time.Second
154+
155+
// kiro-cli emits ANSI terminal escape codes that are not
156+
// suitable for streaming. Capture and return stripped text.
157+
var stdout, stderr bytes.Buffer
158+
cmd.Stdout = &stdout
159+
cmd.Stderr = &stderr
160+
161+
if err := cmd.Run(); err != nil {
162+
return "", fmt.Errorf(
163+
"kiro failed: %w\nstderr: %s",
164+
err, stderr.String(),
165+
)
166+
}
167+
168+
// Prefer the stream that contains a "> " review marker.
169+
// - stdout with marker and content → use stdout
170+
// - stdout empty or marker-only → try stderr
171+
// - stdout has content but no marker → use stderr only
172+
// if stderr has a marker (otherwise keep stdout)
173+
result, stdoutMarker := stripKiroReview(stdout.String())
174+
if !stdoutMarker || len(result) == 0 {
175+
alt, stderrMarker := stripKiroReview(stderr.String())
176+
if len(alt) > 0 && (len(result) == 0 || stderrMarker) {
177+
result = alt
178+
}
179+
}
180+
if len(result) == 0 {
181+
return "No review output generated", nil
182+
}
183+
if sw := newSyncWriter(output); sw != nil {
184+
_, _ = sw.Write([]byte(result + "\n"))
185+
}
186+
return result, nil
187+
}
188+
189+
func init() {
190+
Register(NewKiroAgent(""))
191+
}

0 commit comments

Comments
 (0)