Merge pull request #572 from oksusucha/feat/add-kiro-cli-agent

feat: add Kiro CLI as AI agent

Co-authored-by: oksusucha <sol4877@gmail.com>
This commit is contained in:
lucaronin
2026-05-18 13:55:43 +02:00
17 changed files with 559 additions and 47 deletions

View File

@@ -8,6 +8,7 @@ pub enum AiAgentId {
Opencode,
Pi,
Gemini,
Kiro,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
@@ -31,6 +32,7 @@ pub struct AiAgentsStatus {
pub opencode: AiAgentAvailability,
pub pi: AiAgentAvailability,
pub gemini: AiAgentAvailability,
pub kiro: AiAgentAvailability,
}
#[derive(Debug, Clone, Serialize)]
@@ -86,6 +88,7 @@ pub fn get_ai_agents_status() -> AiAgentsStatus {
opencode: crate::opencode_cli::check_cli(),
pi: crate::pi_cli::check_cli(),
gemini: crate::gemini_cli::check_cli(),
kiro: crate::kiro_cli::check_cli(),
}
}
@@ -149,9 +152,28 @@ where
};
crate::gemini_cli::run_agent_stream(mapped, emit)
}
AiAgentId::Kiro => run_kiro_agent_stream(request, permission_mode, emit),
}
}
fn run_kiro_agent_stream<F>(
request: AiAgentStreamRequest,
permission_mode: AiAgentPermissionMode,
emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let mapped = crate::cli_agent_runtime::AgentStreamRequest {
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
vault_paths: request.vault_paths,
permission_mode,
};
crate::kiro_cli::run_agent_stream(mapped, emit)
}
fn availability_from_claude() -> AiAgentAvailability {
let status = crate::claude_cli::check_cli();
AiAgentAvailability {

View File

@@ -455,6 +455,97 @@ where
Ok(run.session_id)
}
/// Shared binary discovery: look up a CLI command by name using PATH, login shell, then candidates.
pub(crate) fn find_cli_binary(
name: &str,
candidates: Vec<PathBuf>,
label: &str,
install_hint: &str,
) -> Result<PathBuf, String> {
if let Some(binary) = find_binary_on_path(name) {
return Ok(binary);
}
if let Some(binary) = find_binary_in_user_shell(name) {
return Ok(binary);
}
if let Some(binary) = find_executable_binary_candidate(candidates, label)? {
return Ok(binary);
}
Err(format!("{label} not found. Install it: {install_hint}"))
}
fn find_binary_on_path(name: &str) -> Option<PathBuf> {
let cmd = if cfg!(windows) { "where" } else { "which" };
crate::hidden_command(cmd)
.arg(name)
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn find_binary_in_user_shell(name: &str) -> Option<PathBuf> {
shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, name))
}
fn shell_candidates() -> Vec<PathBuf> {
let mut shells = Vec::new();
if let Some(shell) = std::env::var_os("SHELL") {
if !shell.is_empty() {
shells.push(PathBuf::from(shell));
}
}
shells.push(PathBuf::from("/bin/zsh"));
shells.push(PathBuf::from("/bin/bash"));
shells
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
crate::hidden_command(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
if output.status.success() {
first_existing_path(&String::from_utf8_lossy(&output.stdout))
} else {
None
}
}
pub(crate) fn first_existing_path(stdout: &str) -> Option<PathBuf> {
stdout.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
})
}
/// Shared check_cli pattern for CLI agents.
pub(crate) fn check_cli_availability(
find_binary: impl FnOnce() -> Result<PathBuf, String>,
) -> crate::ai_agents::AiAgentAvailability {
match find_binary() {
Ok(binary) => crate::ai_agents::AiAgentAvailability {
installed: true,
version: version_for_binary(&binary),
},
Err(_) => crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
}
}
#[cfg(test)]
mod tests {
use super::*;

320
src-tauri/src/kiro_cli.rs Normal file
View File

@@ -0,0 +1,320 @@
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
use crate::cli_agent_runtime::AgentStreamRequest;
use regex::Regex;
use std::io::{BufRead, Read, Write};
use std::path::Path;
use std::process::{ChildStderr, ChildStdin, ChildStdout, Stdio};
struct KiroMcpConfig<'a> {
vault_path: &'a str,
vault_paths: &'a [String],
mcp_server_path: &'a str,
}
struct KiroError<'a> {
stderr_output: &'a str,
status: String,
}
pub fn check_cli() -> AiAgentAvailability {
crate::kiro_discovery::check_cli()
}
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = crate::kiro_discovery::find_binary()?;
ensure_mcp_config(&request)?;
let prompt =
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref());
let mut child = spawn_kiro_process(&binary, Path::new(&request.vault_path))?;
let prompt_handle = write_prompt_async(
child.stdin.take().ok_or("No stdin handle")?,
prompt.into_bytes(),
);
let stderr_handle = read_stderr_async(child.stderr.take().ok_or("No stderr handle")?);
let session_id = generate_session_id();
emit(AiAgentStreamEvent::Init {
session_id: session_id.clone(),
});
stream_stdout(child.stdout.take().ok_or("No stdout handle")?, &mut emit);
let mut stderr_output = stderr_handle.join().unwrap_or_default();
if let Some(error) = prompt_write_error(prompt_handle) {
append_stderr_line(&mut stderr_output, error);
}
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
if !status.success() {
emit(AiAgentStreamEvent::Error {
message: format_kiro_error(KiroError {
stderr_output: &stderr_output,
status: status.to_string(),
}),
});
}
emit(AiAgentStreamEvent::Done);
Ok(session_id)
}
fn spawn_kiro_process(binary: &Path, vault_path: &Path) -> Result<std::process::Child, String> {
let mut command = crate::hidden_command(binary);
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
command
.arg("chat")
.arg("--no-interactive")
.arg("--trust-all-tools")
.current_dir(vault_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
command
.spawn()
.map_err(|e| format!("Failed to spawn kiro-cli: {e}"))
}
fn write_prompt_async(
mut stdin: ChildStdin,
prompt: Vec<u8>,
) -> std::thread::JoinHandle<Result<(), String>> {
std::thread::spawn(move || {
stdin
.write_all(&prompt)
.map_err(|e| format!("Failed to write kiro-cli stdin: {e}"))
})
}
fn generate_session_id() -> String {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
format!("kiro-{}-{}", std::process::id(), ts)
}
fn stream_stdout<F>(stdout: ChildStdout, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
let reader = std::io::BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(l) if !l.is_empty() => {
emit(AiAgentStreamEvent::TextDelta {
text: format!("{}\n", strip_ansi_codes(&l)),
});
}
Ok(_) => {
emit(AiAgentStreamEvent::TextDelta {
text: "\n".to_string(),
});
}
Err(e) => {
emit(AiAgentStreamEvent::Error {
message: format!("Read error: {e}"),
});
break;
}
}
}
}
fn read_stderr_async(mut stderr: ChildStderr) -> std::thread::JoinHandle<String> {
std::thread::spawn(move || {
let mut output = String::new();
let _ = stderr.read_to_string(&mut output);
output
})
}
fn prompt_write_error(handle: std::thread::JoinHandle<Result<(), String>>) -> Option<String> {
match handle.join() {
Ok(Ok(())) => None,
Ok(Err(error)) => Some(error),
Err(_) => Some("Failed to write kiro-cli stdin: writer thread panicked".into()),
}
}
fn append_stderr_line(stderr_output: &mut String, line: impl AsRef<str>) {
if !stderr_output.is_empty() {
stderr_output.push('\n');
}
stderr_output.push_str(line.as_ref());
}
fn ensure_mcp_config(request: &AgentStreamRequest) -> Result<(), String> {
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
write_mcp_json(KiroMcpConfig {
vault_path: &request.vault_path,
vault_paths: &request.vault_paths,
mcp_server_path: &mcp_server_path,
})
}
fn write_mcp_json(config: KiroMcpConfig<'_>) -> Result<(), String> {
let config_dir = Path::new(config.vault_path).join(".kiro").join("settings");
std::fs::create_dir_all(&config_dir)
.map_err(|e| format!("Failed to create .kiro/settings: {e}"))?;
let config_path = config_dir.join("mcp.json");
let mut json_config: serde_json::Value = std::fs::read_to_string(&config_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| serde_json::json!({}));
let servers = json_config
.as_object_mut()
.ok_or("Invalid mcp.json: not an object")?
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));
let active_vault_paths =
crate::cli_agent_runtime::active_vault_paths_json(config.vault_path, config.vault_paths);
servers["tolaria"] = serde_json::json!({
"command": "node",
"args": [config.mcp_server_path],
"env": {
"VAULT_PATH": config.vault_path,
"VAULT_PATHS": active_vault_paths,
"WS_UI_PORT": "9711"
},
"disabled": false
});
std::fs::write(
&config_path,
serde_json::to_string_pretty(&json_config)
.map_err(|e| format!("JSON serialize error: {e}"))?,
)
.map_err(|e| format!("Failed to write mcp.json: {e}"))?;
Ok(())
}
fn strip_ansi_codes(input: &str) -> String {
static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let re = RE.get_or_init(|| Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").unwrap());
re.replace_all(input, "").to_string()
}
fn format_kiro_error(error: KiroError<'_>) -> String {
if is_auth_error(error.stderr_output) {
return "Kiro CLI is not authenticated. Run `kiro-cli login` in your terminal to sign in."
.into();
}
if error.stderr_output.trim().is_empty() {
format!("kiro-cli exited with status {}", error.status)
} else {
error
.stderr_output
.lines()
.take(3)
.collect::<Vec<_>>()
.join("\n")
}
}
fn is_auth_error(stderr_output: &str) -> bool {
let lower = stderr_output.to_ascii_lowercase();
["auth", "login", "token"]
.iter()
.any(|needle| lower.contains(needle))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_ansi_codes_removes_terminal_colors() {
assert_eq!(
strip_ansi_codes("\x1b[38;5;141m> \x1b[0mHello! \x1b[2K"),
"> Hello! "
);
assert_eq!(strip_ansi_codes("plain text"), "plain text");
}
#[test]
fn format_kiro_error_detects_auth_errors() {
let result = format_kiro_error(KiroError {
stderr_output: "Error: auth token expired",
status: "1".into(),
});
assert!(result.contains("kiro-cli login"));
}
#[test]
fn format_kiro_error_returns_status_for_empty_stderr() {
let result = format_kiro_error(KiroError {
stderr_output: "",
status: "1".into(),
});
assert!(result.contains("status 1"));
}
#[test]
fn write_mcp_json_creates_config() {
let dir = tempfile::tempdir().unwrap();
let vault_path = dir.path().to_str().unwrap();
write_mcp_json(KiroMcpConfig {
vault_path,
vault_paths: &["/other/vault".into(), vault_path.into()],
mcp_server_path: "/opt/mcp/index.js",
})
.unwrap();
let config_path = dir.path().join(".kiro/settings/mcp.json");
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
assert_eq!(content["mcpServers"]["tolaria"]["command"], "node");
assert_eq!(
content["mcpServers"]["tolaria"]["args"][0],
"/opt/mcp/index.js"
);
assert_eq!(
content["mcpServers"]["tolaria"]["env"]["VAULT_PATH"],
vault_path
);
assert_eq!(
content["mcpServers"]["tolaria"]["env"]["VAULT_PATHS"],
serde_json::json!(serde_json::to_string(&vec![vault_path, "/other/vault"]).unwrap())
);
assert_eq!(
content["mcpServers"]["tolaria"]["env"]["WS_UI_PORT"],
"9711"
);
}
#[test]
fn write_mcp_json_merges_preserving_existing_servers() {
let dir = tempfile::tempdir().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config_dir = dir.path().join(".kiro/settings");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("mcp.json"),
r#"{"mcpServers":{"other":{"command":"python","args":["server.py"]}}}"#,
)
.unwrap();
write_mcp_json(KiroMcpConfig {
vault_path,
vault_paths: &[],
mcp_server_path: "/new/index.js",
})
.unwrap();
let content: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(dir.path().join(".kiro/settings/mcp.json")).unwrap(),
)
.unwrap();
assert_eq!(content["mcpServers"]["tolaria"]["args"][0], "/new/index.js");
assert_eq!(content["mcpServers"]["other"]["command"], "python");
}
}

View File

@@ -0,0 +1,59 @@
use crate::ai_agents::AiAgentAvailability;
use std::path::{Path, PathBuf};
pub(crate) fn check_cli() -> AiAgentAvailability {
crate::cli_agent_runtime::check_cli_availability(find_binary)
}
pub(crate) fn find_binary() -> Result<PathBuf, String> {
crate::cli_agent_runtime::find_cli_binary(
"kiro-cli",
kiro_binary_candidates(),
"Kiro CLI",
"https://kiro.dev/docs/cli",
)
}
fn kiro_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| kiro_binary_candidates_for_home(&home))
.unwrap_or_default()
}
fn kiro_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".local/bin/kiro-cli"),
home.join(".kiro/bin/kiro-cli"),
home.join(".local/share/mise/shims/kiro-cli"),
home.join(".asdf/shims/kiro-cli"),
home.join(".npm-global/bin/kiro-cli"),
home.join(".npm/bin/kiro-cli"),
home.join(".bun/bin/kiro-cli"),
PathBuf::from("/usr/local/bin/kiro-cli"),
PathBuf::from("/opt/homebrew/bin/kiro-cli"),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn binary_candidates_include_supported_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = kiro_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/kiro-cli"),
home.join(".kiro/bin/kiro-cli"),
home.join(".npm-global/bin/kiro-cli"),
PathBuf::from("/opt/homebrew/bin/kiro-cli"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
}

View File

@@ -11,6 +11,8 @@ pub mod gemini_cli;
mod gemini_config;
mod gemini_discovery;
pub mod git;
pub mod kiro_cli;
mod kiro_discovery;
#[cfg(any(test, all(desktop, target_os = "linux")))]
mod linux_appimage;
pub mod mcp;

View File

@@ -6,7 +6,8 @@ use crate::ai_models::{normalize_ai_model_providers, AiModelProvider};
const APP_CONFIG_DIR: &str = "com.tolaria.app";
const LEGACY_APP_CONFIG_DIR: &str = "com.laputa.app";
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] = &["claude_code", "codex", "opencode", "pi", "gemini"];
const SUPPORTED_DEFAULT_AI_AGENTS: &[&str] =
&["claude_code", "codex", "opencode", "pi", "gemini", "kiro"];
pub const DEFAULT_HIDE_GITIGNORED_FILES: bool = true;
const SUPPORTED_NOTE_WIDTH_MODES: &[&str] = &["normal", "wide"];
const SUPPORTED_DATE_DISPLAY_FORMATS: &[&str] = &["us", "european", "friendly", "iso"];