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").
21    program: String,
22}
23
24impl KiroCliAdapter {
25    pub fn new(program: Option<String>) -> Self {
26        Self {
27            program: program.unwrap_or_else(|| "kiro".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        // `prompt` is reused as the agent name when called from the launcher
102        // after write_kiro_agent_config has been invoked. If it looks like an
103        // agent name (no whitespace), use --agent; otherwise fall back to
104        // passing the prompt as input.
105        if !prompt.contains(' ') && prompt.starts_with("batty-") {
106            Ok(format!("exec kiro chat --trust-all-tools --agent {prompt}"))
107        } else {
108            let escaped = prompt.replace('\'', "'\\''");
109            Ok(format!(
110                "exec kiro chat --trust-all-tools --model {KIRO_DEFAULT_MODEL} '{escaped}'"
111            ))
112        }
113    }
114
115    fn new_session_id(&self) -> Option<String> {
116        Some(Uuid::new_v4().to_string())
117    }
118
119    fn health_check(&self) -> super::BackendHealth {
120        super::check_binary_available(&self.program)
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn default_program_is_kiro() {
130        let adapter = KiroCliAdapter::new(None);
131        let config = adapter.spawn_config("test", Path::new("/tmp"));
132        assert_eq!(config.program, "kiro");
133    }
134
135    #[test]
136    fn spawn_uses_chat_with_trust_all_tools() {
137        let adapter = KiroCliAdapter::new(None);
138        let config = adapter.spawn_config("ship the patch", Path::new("/tmp/worktree"));
139        assert_eq!(
140            config.args,
141            vec![
142                "chat".to_string(),
143                "--trust-all-tools".to_string(),
144                "ship the patch".to_string()
145            ]
146        );
147        assert_eq!(config.work_dir, "/tmp/worktree");
148    }
149
150    #[test]
151    fn kiro_prefers_agents_md_instruction_order() {
152        let adapter = KiroCliAdapter::new(None);
153        assert_eq!(
154            adapter.instruction_candidates(),
155            &["AGENTS.md", "CLAUDE.md"]
156        );
157    }
158
159    #[test]
160    fn kiro_wraps_launch_prompt() {
161        let adapter = KiroCliAdapter::new(None);
162        let wrapped = adapter.wrap_launch_prompt("Launch body");
163        assert!(wrapped.contains("Kiro under Batty supervision"));
164        assert!(wrapped.contains("Launch body"));
165    }
166
167    #[test]
168    fn launch_command_with_agent_name() {
169        let adapter = KiroCliAdapter::new(None);
170        let cmd = adapter
171            .launch_command("batty-architect", false, false, None)
172            .unwrap();
173        assert_eq!(
174            cmd,
175            "exec kiro chat --trust-all-tools --agent batty-architect"
176        );
177    }
178
179    #[test]
180    fn launch_command_with_raw_prompt_fallback() {
181        let adapter = KiroCliAdapter::new(None);
182        let cmd = adapter
183            .launch_command("do the thing", false, false, None)
184            .unwrap();
185        assert!(cmd.contains("--model"));
186        assert!(cmd.contains("'do the thing'"));
187    }
188
189    #[test]
190    fn launch_command_escapes_single_quotes() {
191        let adapter = KiroCliAdapter::new(None);
192        let cmd = adapter
193            .launch_command("fix user's bug", false, false, None)
194            .unwrap();
195        assert!(cmd.contains("user'\\''s"));
196    }
197
198    #[test]
199    fn write_agent_config_creates_json() {
200        let tmp = tempfile::tempdir().unwrap();
201        let name =
202            write_kiro_agent_config("architect", "You are an architect", tmp.path()).unwrap();
203        assert_eq!(name, "batty-architect");
204        let config_path = tmp.path().join(".kiro/agents/batty-architect.json");
205        assert!(config_path.exists());
206        let content: serde_json::Value =
207            serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
208        assert_eq!(content["prompt"], "You are an architect");
209        assert_eq!(content["model"], KIRO_DEFAULT_MODEL);
210        assert_eq!(content["tools"], serde_json::json!(["*"]));
211        assert_eq!(content["allowedTools"], serde_json::json!(["*"]));
212    }
213
214    #[test]
215    fn new_session_id_returns_uuid() {
216        let adapter = KiroCliAdapter::new(None);
217        let sid = adapter.new_session_id();
218        assert!(sid.is_some());
219        assert!(!sid.unwrap().is_empty());
220    }
221
222    #[test]
223    fn supports_resume_is_false() {
224        let adapter = KiroCliAdapter::new(None);
225        assert!(!adapter.supports_resume());
226    }
227}