From 16ce8b105c457eb28c840480b066ac0188fbf839 Mon Sep 17 00:00:00 2001 From: oksusucha Date: Thu, 7 May 2026 17:01:44 +0900 Subject: [PATCH 1/3] feat: add Kiro CLI as AI agent (rebuilt on upstream architecture) - Add kiro_discovery.rs: binary detection (PATH, login shell, known paths) - Add kiro_cli.rs: streaming via stdin, ANSI stripping, MCP config merge - Register Kiro in ai_agents.rs enum, status, and dispatcher - Add 'kiro' to settings normalization - Add Kiro to frontend agent definitions and helpers - Update test for agent cycling Kiro uses plain text streaming (not JSON) so it has its own stream handler rather than using cli_agent_runtime::run_ai_agent_json_stream. MCP config merges into existing .kiro/settings/mcp.json to preserve user-configured servers. Prompt is passed via stdin to avoid E2BIG. --- src-tauri/src/ai_agents.rs | 12 ++ src-tauri/src/kiro_cli.rs | 210 ++++++++++++++++++++++++++++++++ src-tauri/src/kiro_discovery.rs | 142 +++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/settings.rs | 2 +- src/lib/aiAgents.test.ts | 3 +- src/lib/aiAgents.ts | 11 +- src/mock-tauri/mock-handlers.ts | 1 + 8 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 src-tauri/src/kiro_cli.rs create mode 100644 src-tauri/src/kiro_discovery.rs diff --git a/src-tauri/src/ai_agents.rs b/src-tauri/src/ai_agents.rs index 236debc1..5d7487ff 100644 --- a/src-tauri/src/ai_agents.rs +++ b/src-tauri/src/ai_agents.rs @@ -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)] @@ -84,6 +86,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(), } } @@ -142,6 +145,15 @@ where }; crate::gemini_cli::run_agent_stream(mapped, emit) } + AiAgentId::Kiro => { + let mapped = crate::cli_agent_runtime::AgentStreamRequest { + message: request.message, + system_prompt: request.system_prompt, + vault_path: request.vault_path, + permission_mode, + }; + crate::kiro_cli::run_agent_stream(mapped, emit) + } } } diff --git a/src-tauri/src/kiro_cli.rs b/src-tauri/src/kiro_cli.rs new file mode 100644 index 00000000..705a1d66 --- /dev/null +++ b/src-tauri/src/kiro_cli.rs @@ -0,0 +1,210 @@ +use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent}; +use crate::cli_agent_runtime::AgentStreamRequest; +use regex::Regex; +use std::io::BufRead; +use std::path::Path; +use std::process::Stdio; + +pub fn check_cli() -> AiAgentAvailability { + crate::kiro_discovery::check_cli() +} + +pub fn run_agent_stream(request: AgentStreamRequest, mut emit: F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let binary = crate::kiro_discovery::find_binary()?; + ensure_mcp_config(&request.vault_path)?; + let prompt = crate::cli_agent_runtime::build_prompt( + &request.message, + request.system_prompt.as_deref(), + ); + + 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(&request.vault_path) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut child = command + .spawn() + .map_err(|e| format!("Failed to spawn kiro-cli: {e}"))?; + + // Write prompt from a separate thread to avoid deadlock when prompt > pipe buffer (64KB) + let stdin = child.stdin.take().ok_or("No stdin handle")?; + let prompt_handle = std::thread::spawn(move || { + use std::io::Write; + let mut stdin = stdin; + let _ = stdin.write_all(prompt.as_bytes()); + }); + + let session_id = format!("kiro-{}-{}", std::process::id(), std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0)); + emit(AiAgentStreamEvent::Init { + session_id: session_id.clone(), + }); + + let stdout = child.stdout.take().ok_or("No stdout handle")?; + let reader = std::io::BufReader::new(stdout); + + for line in reader.lines() { + let line = match line { + Ok(l) => l, + Err(e) => { + emit(AiAgentStreamEvent::Error { + message: format!("Read error: {e}"), + }); + break; + } + }; + + if !line.is_empty() { + let clean = strip_ansi_codes(&line); + emit(AiAgentStreamEvent::TextDelta { + text: format!("{clean}\n"), + }); + } else { + emit(AiAgentStreamEvent::TextDelta { + text: "\n".to_string(), + }); + } + } + + let stderr_output = child + .stderr + .take() + .and_then(|stderr| std::io::read_to_string(stderr).ok()) + .unwrap_or_default(); + + let _ = prompt_handle.join(); + let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?; + if !status.success() { + emit(AiAgentStreamEvent::Error { + message: format_kiro_error(stderr_output, status.to_string()), + }); + } + + emit(AiAgentStreamEvent::Done); + Ok(session_id) +} + +fn ensure_mcp_config(vault_path: &str) -> Result<(), String> { + let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?; + write_mcp_json(vault_path, &mcp_server_path) +} + +fn write_mcp_json(vault_path: &str, mcp_server_path: &str) -> Result<(), String> { + let config_dir = Path::new(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 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 = config + .as_object_mut() + .ok_or("Invalid mcp.json: not an object")? + .entry("mcpServers") + .or_insert_with(|| serde_json::json!({})); + + servers["tolaria"] = serde_json::json!({ + "command": "node", + "args": [mcp_server_path], + "env": { "VAULT_PATH": vault_path }, + "disabled": false + }); + + std::fs::write( + &config_path, + serde_json::to_string_pretty(&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 = std::sync::OnceLock::new(); + let re = RE.get_or_init(|| Regex::new(r"\x1b\[[0-9;]*m").unwrap()); + re.replace_all(input, "").to_string() +} + +fn format_kiro_error(stderr_output: String, status: String) -> String { + let lower = stderr_output.to_ascii_lowercase(); + if lower.contains("auth") || lower.contains("login") || lower.contains("token") { + return "Kiro CLI is not authenticated. Run `kiro-cli login` in your terminal to sign in.".into(); + } + if stderr_output.trim().is_empty() { + format!("kiro-cli exited with status {status}") + } else { + stderr_output.lines().take(3).collect::>().join("\n") + } +} + +#[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[0m"), + "> Hello! " + ); + assert_eq!(strip_ansi_codes("plain text"), "plain text"); + } + + #[test] + fn format_kiro_error_detects_auth_errors() { + let result = format_kiro_error("Error: auth token expired".into(), "1".into()); + assert!(result.contains("kiro-cli login")); + } + + #[test] + fn format_kiro_error_returns_status_for_empty_stderr() { + let result = format_kiro_error("".into(), "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(vault_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"); + } + + #[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(vault_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"); + } +} diff --git a/src-tauri/src/kiro_discovery.rs b/src-tauri/src/kiro_discovery.rs new file mode 100644 index 00000000..4b169c67 --- /dev/null +++ b/src-tauri/src/kiro_discovery.rs @@ -0,0 +1,142 @@ +use crate::ai_agents::AiAgentAvailability; +use std::path::{Path, PathBuf}; + +pub(crate) fn check_cli() -> AiAgentAvailability { + match find_binary() { + Ok(binary) => AiAgentAvailability { + installed: true, + version: crate::cli_agent_runtime::version_for_binary(&binary), + }, + Err(_) => AiAgentAvailability { + installed: false, + version: None, + }, + } +} + +pub(crate) fn find_binary() -> Result { + if let Some(binary) = find_binary_on_path() { + return Ok(binary); + } + if let Some(binary) = find_binary_in_user_shell() { + return Ok(binary); + } + if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate( + kiro_binary_candidates(), + "Kiro CLI", + )? { + return Ok(binary); + } + + Err("Kiro CLI not found. Install it: https://kiro.dev/docs/cli".into()) +} + +fn find_binary_on_path() -> Option { + crate::hidden_command(path_lookup_command()) + .arg("kiro-cli") + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) +} + +fn path_lookup_command() -> &'static str { + if cfg!(windows) { "where" } else { "which" } +} + +fn find_binary_in_user_shell() -> Option { + user_shell_candidates() + .into_iter() + .filter(|shell| shell.exists()) + .find_map(|shell| command_path_from_shell(&shell, "kiro-cli")) +} + +fn user_shell_candidates() -> Vec { + 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 { + 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 { + if output.status.success() { + first_existing_path(&String::from_utf8_lossy(&output.stdout)) + } else { + None + } +} + +fn first_existing_path(stdout: &str) -> Option { + 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) + }) +} + +fn kiro_binary_candidates() -> Vec { + dirs::home_dir() + .map(|home| kiro_binary_candidates_for_home(&home)) + .unwrap_or_default() +} + +fn kiro_binary_candidates_for_home(home: &Path) -> Vec { + 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()); + } + } + + #[test] + fn first_existing_path_skips_empty_and_missing_lines() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing-kiro"); + let kiro = dir.path().join("kiro-cli"); + std::fs::write(&kiro, "#!/bin/sh\n").unwrap(); + + let stdout = format!("\n{}\n{}\n", missing.display(), kiro.display()); + assert_eq!(first_existing_path(&stdout), Some(kiro)); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6fb21964..6f345ad7 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -9,6 +9,8 @@ pub mod frontmatter; pub mod gemini_cli; mod gemini_config; mod gemini_discovery; +pub mod kiro_cli; +mod kiro_discovery; pub mod git; #[cfg(any(test, all(desktop, target_os = "linux")))] mod linux_appimage; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index eedfc9b8..c227adbe 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -6,7 +6,7 @@ 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_UI_LANGUAGE_ALIASES: &[(&str, &str)] = &[ diff --git a/src/lib/aiAgents.test.ts b/src/lib/aiAgents.test.ts index 2f64057a..58688455 100644 --- a/src/lib/aiAgents.test.ts +++ b/src/lib/aiAgents.test.ts @@ -42,6 +42,7 @@ describe('aiAgents helpers', () => { expect(getNextAiAgentId('codex')).toBe('opencode') expect(getNextAiAgentId('opencode')).toBe('pi') expect(getNextAiAgentId('pi')).toBe('gemini') - expect(getNextAiAgentId('gemini')).toBe('claude_code') + expect(getNextAiAgentId('gemini')).toBe('kiro') + expect(getNextAiAgentId('kiro')).toBe('claude_code') }) }) diff --git a/src/lib/aiAgents.ts b/src/lib/aiAgents.ts index 1ffd11f7..e933e6a7 100644 --- a/src/lib/aiAgents.ts +++ b/src/lib/aiAgents.ts @@ -1,4 +1,4 @@ -export type AiAgentId = 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' +export type AiAgentId = 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | 'kiro' export type AiAgentStatus = 'checking' | 'installed' | 'missing' export type AiAgentReadiness = 'checking' | 'ready' | 'missing' @@ -50,6 +50,12 @@ export const AI_AGENT_DEFINITIONS: readonly AiAgentDefinition[] = [ shortLabel: 'Gemini', installUrl: 'https://google-gemini.github.io/gemini-cli/', }, + { + id: 'kiro', + label: 'Kiro', + shortLabel: 'Kiro', + installUrl: 'https://kiro.dev/docs/cli', + }, ] as const export function createAiAgentAvailability(status: AiAgentStatus = 'checking', version: string | null = null): AiAgentAvailability { @@ -63,6 +69,7 @@ export function createCheckingAiAgentsStatus(): AiAgentsStatus { opencode: createAiAgentAvailability(), pi: createAiAgentAvailability(), gemini: createAiAgentAvailability(), + kiro: createAiAgentAvailability(), } } @@ -73,6 +80,7 @@ export function createMissingAiAgentsStatus(): AiAgentsStatus { opencode: createAiAgentAvailability('missing'), pi: createAiAgentAvailability('missing'), gemini: createAiAgentAvailability('missing'), + kiro: createAiAgentAvailability('missing'), } } @@ -104,6 +112,7 @@ export function normalizeAiAgentsStatus(payload: Partial any> = { opencode: { installed: false, version: null }, pi: { installed: false, version: null }, gemini: { installed: false, version: null }, + kiro: { installed: false, version: null }, }), get_agent_docs_path: () => '/mock/Tolaria/resources/agent-docs', get_vault_ai_guidance_status: () => ({ ...mockVaultAiGuidanceStatus }), From 596d2b93bbf84aa9c5bec9af5db52ba011c19480 Mon Sep 17 00:00:00 2001 From: oksusucha Date: Thu, 7 May 2026 17:13:16 +0900 Subject: [PATCH 2/3] refactor: reduce kiro_cli complexity by extracting helper functions Split run_agent_stream into focused helpers: - spawn_kiro_process: command setup and spawn - write_prompt_async: threaded stdin write - generate_session_id: unique ID generation - stream_stdout: line-by-line output processing - collect_stderr: stderr collection Reduces per-function cyclomatic complexity from ~18 to ~6. --- src-tauri/src/kiro_cli.rs | 127 +++++++++++++++++++++----------------- 1 file changed, 72 insertions(+), 55 deletions(-) diff --git a/src-tauri/src/kiro_cli.rs b/src-tauri/src/kiro_cli.rs index 705a1d66..794d2660 100644 --- a/src-tauri/src/kiro_cli.rs +++ b/src-tauri/src/kiro_cli.rs @@ -20,67 +20,17 @@ where request.system_prompt.as_deref(), ); - 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(&request.vault_path) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); + let mut child = spawn_kiro_process(&binary, &request.vault_path)?; + let prompt_handle = write_prompt_async(child.stdin.take().ok_or("No stdin handle")?, prompt); - let mut child = command - .spawn() - .map_err(|e| format!("Failed to spawn kiro-cli: {e}"))?; - - // Write prompt from a separate thread to avoid deadlock when prompt > pipe buffer (64KB) - let stdin = child.stdin.take().ok_or("No stdin handle")?; - let prompt_handle = std::thread::spawn(move || { - use std::io::Write; - let mut stdin = stdin; - let _ = stdin.write_all(prompt.as_bytes()); - }); - - let session_id = format!("kiro-{}-{}", std::process::id(), std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0)); + let session_id = generate_session_id(); emit(AiAgentStreamEvent::Init { session_id: session_id.clone(), }); - let stdout = child.stdout.take().ok_or("No stdout handle")?; - let reader = std::io::BufReader::new(stdout); - - for line in reader.lines() { - let line = match line { - Ok(l) => l, - Err(e) => { - emit(AiAgentStreamEvent::Error { - message: format!("Read error: {e}"), - }); - break; - } - }; - - if !line.is_empty() { - let clean = strip_ansi_codes(&line); - emit(AiAgentStreamEvent::TextDelta { - text: format!("{clean}\n"), - }); - } else { - emit(AiAgentStreamEvent::TextDelta { - text: "\n".to_string(), - }); - } - } - - let stderr_output = child - .stderr - .take() - .and_then(|stderr| std::io::read_to_string(stderr).ok()) - .unwrap_or_default(); + stream_stdout(&mut child, &mut emit); + let stderr_output = collect_stderr(&mut child); let _ = prompt_handle.join(); let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?; if !status.success() { @@ -93,6 +43,73 @@ where Ok(session_id) } +fn spawn_kiro_process(binary: &Path, vault_path: &str) -> Result { + 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(stdin: std::process::ChildStdin, prompt: String) -> std::thread::JoinHandle<()> { + std::thread::spawn(move || { + use std::io::Write; + let mut stdin = stdin; + let _ = stdin.write_all(prompt.as_bytes()); + }) +} + +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(child: &mut std::process::Child, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + let Some(stdout) = child.stdout.take() else { return }; + 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 collect_stderr(child: &mut std::process::Child) -> String { + child + .stderr + .take() + .and_then(|stderr| std::io::read_to_string(stderr).ok()) + .unwrap_or_default() +} + fn ensure_mcp_config(vault_path: &str) -> Result<(), String> { let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?; write_mcp_json(vault_path, &mcp_server_path) From 881f86050477a99ee0022245f9b932525389468f Mon Sep 17 00:00:00 2001 From: oksusucha Date: Thu, 7 May 2026 17:15:27 +0900 Subject: [PATCH 3/3] refactor: extract shared binary discovery into cli_agent_runtime Move common shell lookup logic (PATH check, login shell probe, first_existing_path) into cli_agent_runtime as find_cli_binary() and check_cli_availability(). Rewrite kiro_discovery to use these shared utilities, reducing duplication from 100 lines to ~20. Other discovery modules (pi, gemini, opencode) can adopt the same pattern in a follow-up. --- src-tauri/src/cli_agent_runtime.rs | 91 ++++++++++++++++++++++++++++ src-tauri/src/kiro_discovery.rs | 97 ++---------------------------- 2 files changed, 96 insertions(+), 92 deletions(-) diff --git a/src-tauri/src/cli_agent_runtime.rs b/src-tauri/src/cli_agent_runtime.rs index 54e87e15..222feedd 100644 --- a/src-tauri/src/cli_agent_runtime.rs +++ b/src-tauri/src/cli_agent_runtime.rs @@ -275,6 +275,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, + label: &str, + install_hint: &str, +) -> Result { + 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 { + 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 { + shell_candidates() + .into_iter() + .filter(|shell| shell.exists()) + .find_map(|shell| command_path_from_shell(&shell, name)) +} + +fn shell_candidates() -> Vec { + 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 { + 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 { + if output.status.success() { + first_existing_path(&String::from_utf8_lossy(&output.stdout)) + } else { + None + } +} + +pub(crate) fn first_existing_path(stdout: &str) -> Option { + 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, +) -> 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::*; diff --git a/src-tauri/src/kiro_discovery.rs b/src-tauri/src/kiro_discovery.rs index 4b169c67..604ec7c4 100644 --- a/src-tauri/src/kiro_discovery.rs +++ b/src-tauri/src/kiro_discovery.rs @@ -2,92 +2,16 @@ use crate::ai_agents::AiAgentAvailability; use std::path::{Path, PathBuf}; pub(crate) fn check_cli() -> AiAgentAvailability { - match find_binary() { - Ok(binary) => AiAgentAvailability { - installed: true, - version: crate::cli_agent_runtime::version_for_binary(&binary), - }, - Err(_) => AiAgentAvailability { - installed: false, - version: None, - }, - } + crate::cli_agent_runtime::check_cli_availability(find_binary) } pub(crate) fn find_binary() -> Result { - if let Some(binary) = find_binary_on_path() { - return Ok(binary); - } - if let Some(binary) = find_binary_in_user_shell() { - return Ok(binary); - } - if let Some(binary) = crate::cli_agent_runtime::find_executable_binary_candidate( + crate::cli_agent_runtime::find_cli_binary( + "kiro-cli", kiro_binary_candidates(), "Kiro CLI", - )? { - return Ok(binary); - } - - Err("Kiro CLI not found. Install it: https://kiro.dev/docs/cli".into()) -} - -fn find_binary_on_path() -> Option { - crate::hidden_command(path_lookup_command()) - .arg("kiro-cli") - .output() - .ok() - .and_then(|output| path_from_successful_output(&output)) -} - -fn path_lookup_command() -> &'static str { - if cfg!(windows) { "where" } else { "which" } -} - -fn find_binary_in_user_shell() -> Option { - user_shell_candidates() - .into_iter() - .filter(|shell| shell.exists()) - .find_map(|shell| command_path_from_shell(&shell, "kiro-cli")) -} - -fn user_shell_candidates() -> Vec { - 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 { - 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 { - if output.status.success() { - first_existing_path(&String::from_utf8_lossy(&output.stdout)) - } else { - None - } -} - -fn first_existing_path(stdout: &str) -> Option { - 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) - }) + "https://kiro.dev/docs/cli", + ) } fn kiro_binary_candidates() -> Vec { @@ -128,15 +52,4 @@ mod tests { assert!(candidates.contains(&candidate), "missing {}", candidate.display()); } } - - #[test] - fn first_existing_path_skips_empty_and_missing_lines() { - let dir = tempfile::tempdir().unwrap(); - let missing = dir.path().join("missing-kiro"); - let kiro = dir.path().join("kiro-cli"); - std::fs::write(&kiro, "#!/bin/sh\n").unwrap(); - - let stdout = format!("\n{}\n{}\n", missing.display(), kiro.display()); - assert_eq!(first_existing_path(&stdout), Some(kiro)); - } }