2026-05-07 17:01:44 +09:00
|
|
|
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
|
|
|
|
|
use crate::cli_agent_runtime::AgentStreamRequest;
|
|
|
|
|
use regex::Regex;
|
2026-05-18 13:55:43 +02:00
|
|
|
use std::io::{BufRead, Read, Write};
|
2026-05-07 17:01:44 +09:00
|
|
|
use std::path::Path;
|
2026-05-18 13:55:43 +02:00
|
|
|
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,
|
|
|
|
|
}
|
2026-05-07 17:01:44 +09:00
|
|
|
|
|
|
|
|
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()?;
|
2026-05-18 13:55:43 +02:00
|
|
|
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(),
|
2026-05-07 17:01:44 +09:00
|
|
|
);
|
2026-05-18 13:55:43 +02:00
|
|
|
let stderr_handle = read_stderr_async(child.stderr.take().ok_or("No stderr handle")?);
|
2026-05-07 17:13:16 +09:00
|
|
|
|
|
|
|
|
let session_id = generate_session_id();
|
|
|
|
|
emit(AiAgentStreamEvent::Init {
|
|
|
|
|
session_id: session_id.clone(),
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-18 13:55:43 +02:00
|
|
|
stream_stdout(child.stdout.take().ok_or("No stdout handle")?, &mut emit);
|
2026-05-07 17:13:16 +09:00
|
|
|
|
2026-05-18 13:55:43 +02:00
|
|
|
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);
|
|
|
|
|
}
|
2026-05-07 17:13:16 +09:00
|
|
|
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
|
|
|
|
|
if !status.success() {
|
|
|
|
|
emit(AiAgentStreamEvent::Error {
|
2026-05-18 13:55:43 +02:00
|
|
|
message: format_kiro_error(KiroError {
|
|
|
|
|
stderr_output: &stderr_output,
|
|
|
|
|
status: status.to_string(),
|
|
|
|
|
}),
|
2026-05-07 17:13:16 +09:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
emit(AiAgentStreamEvent::Done);
|
|
|
|
|
Ok(session_id)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 13:55:43 +02:00
|
|
|
fn spawn_kiro_process(binary: &Path, vault_path: &Path) -> Result<std::process::Child, String> {
|
2026-05-07 17:13:16 +09:00
|
|
|
let mut command = crate::hidden_command(binary);
|
|
|
|
|
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
|
2026-05-07 17:01:44 +09:00
|
|
|
command
|
|
|
|
|
.arg("chat")
|
|
|
|
|
.arg("--no-interactive")
|
|
|
|
|
.arg("--trust-all-tools")
|
2026-05-07 17:13:16 +09:00
|
|
|
.current_dir(vault_path)
|
2026-05-07 17:01:44 +09:00
|
|
|
.stdin(Stdio::piped())
|
|
|
|
|
.stdout(Stdio::piped())
|
|
|
|
|
.stderr(Stdio::piped());
|
2026-05-18 13:55:43 +02:00
|
|
|
command
|
|
|
|
|
.spawn()
|
|
|
|
|
.map_err(|e| format!("Failed to spawn kiro-cli: {e}"))
|
2026-05-07 17:13:16 +09:00
|
|
|
}
|
2026-05-07 17:01:44 +09:00
|
|
|
|
2026-05-18 13:55:43 +02:00
|
|
|
fn write_prompt_async(
|
|
|
|
|
mut stdin: ChildStdin,
|
|
|
|
|
prompt: Vec<u8>,
|
|
|
|
|
) -> std::thread::JoinHandle<Result<(), String>> {
|
2026-05-07 17:13:16 +09:00
|
|
|
std::thread::spawn(move || {
|
2026-05-18 13:55:43 +02:00
|
|
|
stdin
|
|
|
|
|
.write_all(&prompt)
|
|
|
|
|
.map_err(|e| format!("Failed to write kiro-cli stdin: {e}"))
|
2026-05-07 17:13:16 +09:00
|
|
|
})
|
|
|
|
|
}
|
2026-05-07 17:01:44 +09:00
|
|
|
|
2026-05-07 17:13:16 +09:00
|
|
|
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)
|
|
|
|
|
}
|
2026-05-07 17:01:44 +09:00
|
|
|
|
2026-05-18 13:55:43 +02:00
|
|
|
fn stream_stdout<F>(stdout: ChildStdout, emit: &mut F)
|
2026-05-07 17:13:16 +09:00
|
|
|
where
|
|
|
|
|
F: FnMut(AiAgentStreamEvent),
|
|
|
|
|
{
|
2026-05-07 17:01:44 +09:00
|
|
|
let reader = std::io::BufReader::new(stdout);
|
|
|
|
|
|
|
|
|
|
for line in reader.lines() {
|
2026-05-07 17:13:16 +09:00
|
|
|
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(),
|
|
|
|
|
});
|
|
|
|
|
}
|
2026-05-07 17:01:44 +09:00
|
|
|
Err(e) => {
|
|
|
|
|
emit(AiAgentStreamEvent::Error {
|
|
|
|
|
message: format!("Read error: {e}"),
|
|
|
|
|
});
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-07 17:13:16 +09:00
|
|
|
}
|
2026-05-07 17:01:44 +09:00
|
|
|
|
2026-05-18 13:55:43 +02:00
|
|
|
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());
|
2026-05-07 17:01:44 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-18 13:55:43 +02:00
|
|
|
fn ensure_mcp_config(request: &AgentStreamRequest) -> Result<(), String> {
|
2026-05-07 17:01:44 +09:00
|
|
|
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
|
2026-05-18 13:55:43 +02:00
|
|
|
write_mcp_json(KiroMcpConfig {
|
|
|
|
|
vault_path: &request.vault_path,
|
|
|
|
|
vault_paths: &request.vault_paths,
|
|
|
|
|
mcp_server_path: &mcp_server_path,
|
|
|
|
|
})
|
2026-05-07 17:01:44 +09:00
|
|
|
}
|
|
|
|
|
|
2026-05-18 13:55:43 +02:00
|
|
|
fn write_mcp_json(config: KiroMcpConfig<'_>) -> Result<(), String> {
|
|
|
|
|
let config_dir = Path::new(config.vault_path).join(".kiro").join("settings");
|
2026-05-07 17:01:44 +09:00
|
|
|
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");
|
|
|
|
|
|
2026-05-18 13:55:43 +02:00
|
|
|
let mut json_config: serde_json::Value = std::fs::read_to_string(&config_path)
|
2026-05-07 17:01:44 +09:00
|
|
|
.ok()
|
|
|
|
|
.and_then(|s| serde_json::from_str(&s).ok())
|
|
|
|
|
.unwrap_or_else(|| serde_json::json!({}));
|
|
|
|
|
|
2026-05-18 13:55:43 +02:00
|
|
|
let servers = json_config
|
2026-05-07 17:01:44 +09:00
|
|
|
.as_object_mut()
|
|
|
|
|
.ok_or("Invalid mcp.json: not an object")?
|
|
|
|
|
.entry("mcpServers")
|
|
|
|
|
.or_insert_with(|| serde_json::json!({}));
|
|
|
|
|
|
2026-05-18 13:55:43 +02:00
|
|
|
let active_vault_paths =
|
|
|
|
|
crate::cli_agent_runtime::active_vault_paths_json(config.vault_path, config.vault_paths);
|
2026-05-07 17:01:44 +09:00
|
|
|
servers["tolaria"] = serde_json::json!({
|
|
|
|
|
"command": "node",
|
2026-05-18 13:55:43 +02:00
|
|
|
"args": [config.mcp_server_path],
|
|
|
|
|
"env": {
|
|
|
|
|
"VAULT_PATH": config.vault_path,
|
|
|
|
|
"VAULT_PATHS": active_vault_paths,
|
|
|
|
|
"WS_UI_PORT": "9711"
|
|
|
|
|
},
|
2026-05-07 17:01:44 +09:00
|
|
|
"disabled": false
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
std::fs::write(
|
|
|
|
|
&config_path,
|
2026-05-18 13:55:43 +02:00
|
|
|
serde_json::to_string_pretty(&json_config)
|
|
|
|
|
.map_err(|e| format!("JSON serialize error: {e}"))?,
|
2026-05-07 17:01:44 +09:00
|
|
|
)
|
|
|
|
|
.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();
|
2026-05-18 13:55:43 +02:00
|
|
|
let re = RE.get_or_init(|| Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").unwrap());
|
2026-05-07 17:01:44 +09:00
|
|
|
re.replace_all(input, "").to_string()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 13:55:43 +02:00
|
|
|
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();
|
2026-05-07 17:01:44 +09:00
|
|
|
}
|
2026-05-18 13:55:43 +02:00
|
|
|
if error.stderr_output.trim().is_empty() {
|
|
|
|
|
format!("kiro-cli exited with status {}", error.status)
|
2026-05-07 17:01:44 +09:00
|
|
|
} else {
|
2026-05-18 13:55:43 +02:00
|
|
|
error
|
|
|
|
|
.stderr_output
|
|
|
|
|
.lines()
|
|
|
|
|
.take(3)
|
|
|
|
|
.collect::<Vec<_>>()
|
|
|
|
|
.join("\n")
|
2026-05-07 17:01:44 +09:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 13:55:43 +02:00
|
|
|
fn is_auth_error(stderr_output: &str) -> bool {
|
|
|
|
|
let lower = stderr_output.to_ascii_lowercase();
|
|
|
|
|
["auth", "login", "token"]
|
|
|
|
|
.iter()
|
|
|
|
|
.any(|needle| lower.contains(needle))
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 17:01:44 +09:00
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn strip_ansi_codes_removes_terminal_colors() {
|
|
|
|
|
assert_eq!(
|
2026-05-18 13:55:43 +02:00
|
|
|
strip_ansi_codes("\x1b[38;5;141m> \x1b[0mHello! \x1b[2K"),
|
2026-05-07 17:01:44 +09:00
|
|
|
"> Hello! "
|
|
|
|
|
);
|
|
|
|
|
assert_eq!(strip_ansi_codes("plain text"), "plain text");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_kiro_error_detects_auth_errors() {
|
2026-05-18 13:55:43 +02:00
|
|
|
let result = format_kiro_error(KiroError {
|
|
|
|
|
stderr_output: "Error: auth token expired",
|
|
|
|
|
status: "1".into(),
|
|
|
|
|
});
|
2026-05-07 17:01:44 +09:00
|
|
|
assert!(result.contains("kiro-cli login"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn format_kiro_error_returns_status_for_empty_stderr() {
|
2026-05-18 13:55:43 +02:00
|
|
|
let result = format_kiro_error(KiroError {
|
|
|
|
|
stderr_output: "",
|
|
|
|
|
status: "1".into(),
|
|
|
|
|
});
|
2026-05-07 17:01:44 +09:00
|
|
|
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();
|
2026-05-18 13:55:43 +02:00
|
|
|
write_mcp_json(KiroMcpConfig {
|
|
|
|
|
vault_path,
|
|
|
|
|
vault_paths: &["/other/vault".into(), vault_path.into()],
|
|
|
|
|
mcp_server_path: "/opt/mcp/index.js",
|
|
|
|
|
})
|
|
|
|
|
.unwrap();
|
2026-05-07 17:01:44 +09:00
|
|
|
|
|
|
|
|
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");
|
2026-05-18 13:55:43 +02:00
|
|
|
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"
|
|
|
|
|
);
|
2026-05-07 17:01:44 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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"]}}}"#,
|
2026-05-18 13:55:43 +02:00
|
|
|
)
|
|
|
|
|
.unwrap();
|
2026-05-07 17:01:44 +09:00
|
|
|
|
2026-05-18 13:55:43 +02:00
|
|
|
write_mcp_json(KiroMcpConfig {
|
|
|
|
|
vault_path,
|
|
|
|
|
vault_paths: &[],
|
|
|
|
|
mcp_server_path: "/new/index.js",
|
|
|
|
|
})
|
|
|
|
|
.unwrap();
|
2026-05-07 17:01:44 +09:00
|
|
|
|
|
|
|
|
let content: serde_json::Value = serde_json::from_str(
|
|
|
|
|
&std::fs::read_to_string(dir.path().join(".kiro/settings/mcp.json")).unwrap(),
|
2026-05-18 13:55:43 +02:00
|
|
|
)
|
|
|
|
|
.unwrap();
|
2026-05-07 17:01:44 +09:00
|
|
|
assert_eq!(content["mcpServers"]["tolaria"]["args"][0], "/new/index.js");
|
|
|
|
|
assert_eq!(content["mcpServers"]["other"]["command"], "python");
|
|
|
|
|
}
|
|
|
|
|
}
|