1#![cfg_attr(not(test), allow(dead_code))]
7
8use std::path::Path;
9
10use uuid::Uuid;
11
12use crate::agent::{AgentAdapter, SpawnConfig};
13use crate::prompt::PromptPatterns;
14
15pub const KIRO_DEFAULT_MODEL: &str = "claude-opus-4.6-1m";
17
18pub struct KiroCliAdapter {
20 program: String,
22}
23
24impl KiroCliAdapter {
25 pub fn new(program: Option<String>) -> Self {
26 Self {
27 program: program.unwrap_or_else(|| "kiro-cli".to_string()),
28 }
29 }
30}
31
32pub fn write_kiro_agent_config(
35 member_name: &str,
36 prompt: &str,
37 work_dir: &Path,
38) -> anyhow::Result<String> {
39 let agent_name = format!("batty-{member_name}");
40 let agents_dir = work_dir.join(".kiro").join("agents");
41 std::fs::create_dir_all(&agents_dir)?;
42 let config_path = agents_dir.join(format!("{agent_name}.json"));
43 let config = serde_json::json!({
44 "name": agent_name,
45 "description": format!("Batty-managed agent for {member_name}"),
46 "prompt": prompt,
47 "tools": ["*"],
48 "allowedTools": ["*"],
49 "model": KIRO_DEFAULT_MODEL,
50 "includeMcpJson": true
51 });
52 std::fs::write(&config_path, serde_json::to_string_pretty(&config)?)?;
53 Ok(agent_name)
54}
55
56impl AgentAdapter for KiroCliAdapter {
57 fn name(&self) -> &str {
58 "kiro-cli"
59 }
60
61 fn spawn_config(&self, task_description: &str, work_dir: &Path) -> SpawnConfig {
62 SpawnConfig {
63 program: self.program.clone(),
64 args: vec![
65 "chat".to_string(),
66 "--trust-all-tools".to_string(),
67 task_description.to_string(),
68 ],
69 work_dir: work_dir.to_string_lossy().to_string(),
70 env: vec![],
71 }
72 }
73
74 fn prompt_patterns(&self) -> PromptPatterns {
75 PromptPatterns::kiro_cli()
76 }
77
78 fn instruction_candidates(&self) -> &'static [&'static str] {
79 &["AGENTS.md", "CLAUDE.md"]
80 }
81
82 fn wrap_launch_prompt(&self, prompt: &str) -> String {
83 format!(
84 "You are running as Kiro under Batty supervision.\n\
85 Treat the launch context below as authoritative session context.\n\n\
86 {prompt}"
87 )
88 }
89
90 fn format_input(&self, response: &str) -> String {
91 format!("{response}\n")
92 }
93
94 fn launch_command(
95 &self,
96 prompt: &str,
97 _idle: bool,
98 _resume: bool,
99 _session_id: Option<&str>,
100 ) -> anyhow::Result<String> {
101 let escaped_program = self.program.replace('\'', "'\\''");
102 if !prompt.contains(' ') && prompt.starts_with("batty-") {
107 Ok(format!(
108 "exec '{escaped_program}' chat --trust-all-tools --agent {prompt}"
109 ))
110 } else {
111 let escaped = prompt.replace('\'', "'\\''");
112 Ok(format!(
113 "exec '{escaped_program}' chat --trust-all-tools --model {KIRO_DEFAULT_MODEL} '{escaped}'"
114 ))
115 }
116 }
117
118 fn new_session_id(&self) -> Option<String> {
119 Some(Uuid::new_v4().to_string())
120 }
121
122 fn supports_resume(&self) -> bool {
123 true
125 }
126
127 fn health_check(&self) -> super::BackendHealth {
128 super::check_binary_available(&self.program)
129 }
130}
131
132impl KiroCliAdapter {
133 pub fn sdk_launch_command(&self) -> String {
138 crate::shim::kiro_types::kiro_acp_command(&self.program)
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145
146 #[test]
147 fn default_program_is_kiro() {
148 let adapter = KiroCliAdapter::new(None);
149 let config = adapter.spawn_config("test", Path::new("/tmp"));
150 assert_eq!(config.program, "kiro-cli");
151 }
152
153 #[test]
154 fn spawn_uses_chat_with_trust_all_tools() {
155 let adapter = KiroCliAdapter::new(None);
156 let config = adapter.spawn_config("ship the patch", Path::new("/tmp/worktree"));
157 assert_eq!(
158 config.args,
159 vec![
160 "chat".to_string(),
161 "--trust-all-tools".to_string(),
162 "ship the patch".to_string()
163 ]
164 );
165 assert_eq!(config.work_dir, "/tmp/worktree");
166 }
167
168 #[test]
169 fn kiro_prefers_agents_md_instruction_order() {
170 let adapter = KiroCliAdapter::new(None);
171 assert_eq!(
172 adapter.instruction_candidates(),
173 &["AGENTS.md", "CLAUDE.md"]
174 );
175 }
176
177 #[test]
178 fn kiro_wraps_launch_prompt() {
179 let adapter = KiroCliAdapter::new(None);
180 let wrapped = adapter.wrap_launch_prompt("Launch body");
181 assert!(wrapped.contains("Kiro under Batty supervision"));
182 assert!(wrapped.contains("Launch body"));
183 }
184
185 #[test]
186 fn launch_command_with_agent_name() {
187 let adapter = KiroCliAdapter::new(None);
188 let cmd = adapter
189 .launch_command("batty-architect", false, false, None)
190 .unwrap();
191 assert_eq!(
192 cmd,
193 "exec 'kiro-cli' chat --trust-all-tools --agent batty-architect"
194 );
195 }
196
197 #[test]
198 fn launch_command_with_raw_prompt_fallback() {
199 let adapter = KiroCliAdapter::new(None);
200 let cmd = adapter
201 .launch_command("do the thing", false, false, None)
202 .unwrap();
203 assert!(cmd.contains("--model"));
204 assert!(cmd.contains("'do the thing'"));
205 }
206
207 #[test]
208 fn launch_command_escapes_single_quotes() {
209 let adapter = KiroCliAdapter::new(None);
210 let cmd = adapter
211 .launch_command("fix user's bug", false, false, None)
212 .unwrap();
213 assert!(cmd.contains("user'\\''s"));
214 }
215
216 #[test]
217 fn launch_command_uses_configured_program() {
218 let adapter = KiroCliAdapter::new(Some("/opt/kiro-cli".to_string()));
219 let cmd = adapter
220 .launch_command("batty-architect", false, false, None)
221 .unwrap();
222 assert_eq!(
223 cmd,
224 "exec '/opt/kiro-cli' chat --trust-all-tools --agent batty-architect"
225 );
226 }
227
228 #[test]
229 fn write_agent_config_creates_json() {
230 let tmp = tempfile::tempdir().unwrap();
231 let name =
232 write_kiro_agent_config("architect", "You are an architect", tmp.path()).unwrap();
233 assert_eq!(name, "batty-architect");
234 let config_path = tmp.path().join(".kiro/agents/batty-architect.json");
235 assert!(config_path.exists());
236 let content: serde_json::Value =
237 serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
238 assert_eq!(content["prompt"], "You are an architect");
239 assert_eq!(content["model"], KIRO_DEFAULT_MODEL);
240 assert_eq!(content["tools"], serde_json::json!(["*"]));
241 assert_eq!(content["allowedTools"], serde_json::json!(["*"]));
242 }
243
244 #[test]
245 fn new_session_id_returns_uuid() {
246 let adapter = KiroCliAdapter::new(None);
247 let sid = adapter.new_session_id();
248 assert!(sid.is_some());
249 assert!(!sid.unwrap().is_empty());
250 }
251
252 #[test]
253 fn supports_resume_is_true() {
254 let adapter = KiroCliAdapter::new(None);
255 assert!(adapter.supports_resume());
256 }
257
258 #[test]
261 fn sdk_launch_command_uses_acp_mode() {
262 let adapter = KiroCliAdapter::new(None);
263 let cmd = adapter.sdk_launch_command();
264 assert_eq!(cmd, "exec kiro-cli acp --trust-all-tools");
265 }
266
267 #[test]
268 fn sdk_launch_command_custom_binary() {
269 let adapter = KiroCliAdapter::new(Some("/opt/kiro-cli".to_string()));
270 let cmd = adapter.sdk_launch_command();
271 assert_eq!(cmd, "exec /opt/kiro-cli acp --trust-all-tools");
272 }
273}