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:
@@ -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 {
|
||||
|
||||
@@ -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
320
src-tauri/src/kiro_cli.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
59
src-tauri/src/kiro_discovery.rs
Normal file
59
src-tauri/src/kiro_discovery.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"];
|
||||
|
||||
Reference in New Issue
Block a user