Skip to main content

batty_cli/agent/
kiro.rs

1//! Kiro CLI adapter.
2//!
3//! Batty creates a per-member agent config in `.kiro/agents/` and launches
4//! kiro with `--agent batty-<member>`. The agent config carries the system
5//! prompt, model selection, and tool permissions so kiro loads them natively.
6#![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
15/// Default model for Kiro agents.
16pub const KIRO_DEFAULT_MODEL: &str = "claude-opus-4.6-1m";
17
18/// Adapter for the Kiro CLI.
19pub struct KiroCliAdapter {
20    /// Override the kiro binary name/path (default: "kiro-cli").
21    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
32/// Write a `.kiro/agents/batty-<member>.json` agent config so kiro loads the
33/// role prompt as a proper system prompt rather than user input.
34pub 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        // `prompt` is reused as the agent name when called from the launcher
103        // after write_kiro_agent_config has been invoked. If it looks like an
104        // agent name (no whitespace), use --agent; otherwise fall back to
105        // passing the prompt as input.
106        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        // ACP supports session/load for session resumption
124        true
125    }
126
127    fn health_check(&self) -> super::BackendHealth {
128        super::check_binary_available(&self.program)
129    }
130}
131
132impl KiroCliAdapter {
133    /// Build the launch command for ACP (JSON-RPC) SDK mode.
134    ///
135    /// Returns a shell command that starts kiro-cli in ACP mode with
136    /// structured JSON-RPC 2.0 I/O on stdin/stdout.
137    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    // --- SDK launch command tests ---
259
260    #[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}