use std::str;
use crate::cli_common::{CliRunnerBase, MAX_OUTPUT_BYTES};
use crate::types::{
ChatRequest, ChatResponse, ChatStream, LlmCapabilities, LlmProvider, RunnerError, StreamChunk,
};
use async_trait::async_trait;
use tokio::process::Command;
use tracing::instrument;
use crate::config::RunnerConfig;
use crate::process::run_cli_command;
use crate::prompt::prepare_user_prompt;
use crate::sandbox::{apply_sandbox, build_policy};
const DEFAULT_MODEL: &str = "auto";
const FALLBACK_MODELS: &[&str] = &[
"auto",
"claude-sonnet-4.5",
"claude-sonnet-4",
"claude-haiku-4.5",
"deepseek-3.2",
"minimax-m2.1",
"qwen3-coder-next",
];
pub struct KiroCliRunner {
base: CliRunnerBase,
}
impl KiroCliRunner {
#[must_use]
pub fn new(config: RunnerConfig) -> Self {
Self {
base: CliRunnerBase::new(config, DEFAULT_MODEL, FALLBACK_MODELS),
}
}
pub async fn set_session(&self, key: &str, session_id: &str) {
self.base.set_session(key, session_id).await;
}
fn build_command(&self, prompt: &str) -> Command {
let mut cmd = Command::new(&self.base.config.binary_path);
cmd.args([
"chat",
"--no-interactive",
"--wrap",
"never",
"--trust-all-tools",
]);
if let Some(model) = self.base.config.model.as_deref() {
cmd.args(["--model", model]);
}
cmd.arg(prompt);
for arg in &self.base.config.extra_args {
cmd.arg(arg);
}
if let Ok(policy) = build_policy(
self.base.config.working_directory.as_deref(),
&self.base.config.allowed_env_keys,
) {
apply_sandbox(&mut cmd, &policy);
}
cmd
}
fn strip_ansi(input: &str) -> String {
let bytes = input.as_bytes();
let mut output = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == 0x1b && i + 1 < bytes.len() {
match bytes[i + 1] {
b'[' => {
i += 2;
while i < bytes.len() && !(0x40..=0x7e).contains(&bytes[i]) {
i += 1;
}
if i < bytes.len() {
i += 1; }
}
b']' => {
i += 2;
while i < bytes.len() {
if bytes[i] == 0x07 {
i += 1;
break;
}
if bytes[i] == 0x1b && i + 1 < bytes.len() && bytes[i + 1] == b'\\' {
i += 2;
break;
}
i += 1;
}
}
_ => {
i += 2;
}
}
} else {
output.push(bytes[i]);
i += 1;
}
}
String::from_utf8_lossy(&output).into_owned()
}
fn parse_text_response(raw: &[u8]) -> Result<ChatResponse, RunnerError> {
let text = str::from_utf8(raw).map_err(|e| {
RunnerError::internal(format!("Kiro CLI output is not valid UTF-8: {e}"))
})?;
let cleaned = Self::strip_ansi(text);
let content: String = cleaned
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| line.strip_prefix("> ").unwrap_or(line))
.collect::<Vec<_>>()
.join("\n");
Ok(ChatResponse {
content,
model: "kiro".to_owned(),
usage: None,
finish_reason: Some("stop".to_owned()),
warnings: None,
tool_calls: None,
})
}
}
#[async_trait]
impl LlmProvider for KiroCliRunner {
crate::delegate_provider_base!("kiro", "Kiro CLI", LlmCapabilities::empty());
#[instrument(skip_all, fields(runner = "kiro"))]
async fn complete(&self, request: &ChatRequest) -> Result<ChatResponse, RunnerError> {
let prepared = prepare_user_prompt(&request.messages)?;
let prompt = &prepared.prompt;
let mut cmd = self.build_command(prompt);
if let Some(model) = &request.model {
if self.base.get_session(model).await.is_some() {
cmd.arg("--resume");
}
}
let output = run_cli_command(&mut cmd, self.base.config.timeout, MAX_OUTPUT_BYTES).await?;
self.base.check_exit_code(&output, "kiro")?;
let response = Self::parse_text_response(&output.stdout)?;
if let Some(model) = &request.model {
self.base.set_session(model, "active").await;
}
Ok(response)
}
#[instrument(skip_all, fields(runner = "kiro"))]
async fn complete_stream(&self, request: &ChatRequest) -> Result<ChatStream, RunnerError> {
let response = self.complete(request).await?;
let chunk = StreamChunk {
delta: response.content,
is_final: true,
finish_reason: Some("stop".to_owned()),
};
Ok(Box::pin(tokio_stream::once(Ok(chunk))))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_strip_ansi_color_codes() {
let input = "\x1b[32m> Hello\x1b[0m";
let stripped = KiroCliRunner::strip_ansi(input);
assert_eq!(stripped, "> Hello");
}
#[test]
fn test_strip_ansi_cursor_codes() {
let input = "\x1b[?25h\x1b[?25lHello";
let stripped = KiroCliRunner::strip_ansi(input);
assert_eq!(stripped, "Hello");
}
#[test]
fn test_strip_ansi_osc_sequences() {
let input = "\x1b]0;title\x07Hello";
let stripped = KiroCliRunner::strip_ansi(input);
assert_eq!(stripped, "Hello");
}
#[test]
fn test_strip_ansi_no_codes() {
let input = "plain text";
let stripped = KiroCliRunner::strip_ansi(input);
assert_eq!(stripped, "plain text");
}
#[test]
fn test_strip_ansi_empty() {
assert_eq!(KiroCliRunner::strip_ansi(""), "");
}
#[test]
fn test_parse_text_response_with_prefix() {
let raw = b"> Paris is the capital of France.";
let resp = KiroCliRunner::parse_text_response(raw).unwrap();
assert_eq!(resp.content, "Paris is the capital of France.");
}
#[test]
fn test_parse_text_response_multiline() {
let raw = b"> Line one\n> Line two\n> Line three";
let resp = KiroCliRunner::parse_text_response(raw).unwrap();
assert_eq!(resp.content, "Line one\nLine two\nLine three");
}
#[test]
fn test_parse_text_response_with_ansi() {
let raw = b"\x1b[32m> Hello world\x1b[0m";
let resp = KiroCliRunner::parse_text_response(raw).unwrap();
assert_eq!(resp.content, "Hello world");
}
#[test]
fn test_parse_text_response_empty_output() {
let resp = KiroCliRunner::parse_text_response(b"").unwrap();
assert_eq!(resp.content, "");
}
#[test]
fn test_parse_text_response_mixed_lines() {
let raw = b"Some debug info\n> Actual response\n";
let resp = KiroCliRunner::parse_text_response(raw).unwrap();
assert_eq!(resp.content, "Some debug info\nActual response");
}
#[test]
fn test_default_model() {
let config = RunnerConfig::new(PathBuf::from("kiro-cli"));
let runner = KiroCliRunner::new(config);
assert_eq!(runner.default_model(), "auto");
}
#[test]
fn test_available_models() {
let config = RunnerConfig::new(PathBuf::from("kiro-cli"));
let runner = KiroCliRunner::new(config);
let models = runner.available_models();
assert_eq!(models.len(), 7);
assert!(models.contains(&"auto".to_owned()));
assert!(models.contains(&"claude-sonnet-4".to_owned()));
assert!(models.contains(&"deepseek-3.2".to_owned()));
}
#[test]
fn test_capabilities_no_streaming() {
let config = RunnerConfig::new(PathBuf::from("kiro-cli"));
let runner = KiroCliRunner::new(config);
assert!(!runner.capabilities().supports_streaming());
}
#[test]
fn test_name_and_display() {
let config = RunnerConfig::new(PathBuf::from("kiro-cli"));
let runner = KiroCliRunner::new(config);
assert_eq!(runner.name(), "kiro");
assert_eq!(runner.display_name(), "Kiro CLI");
}
}