diff --git a/README.md b/README.md index a20bf16e7..998582b44 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ You can find some Loom walkthroughs below — they are short and to the point: - 🔬 **Open source** — Tolaria is free and open source. I built this for [myself](https://x.com/lucaronin) and for sharing it with others. - 📋 **Standards-based** — Notes are markdown files with YAML frontmatter. No proprietary formats, no locked-in data. Everything works with standard tools if you decide to move away from Tolaria. - 🔍 **Types as lenses, not schemas** — Types in Tolaria are navigation aids, not enforcement mechanisms. There's no required fields, no validation, just helpful categories for finding notes. -- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code and Codex CLI (for now), but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out. +- 🪄**AI-first but not AI-only** — A vault of files works very well with AI agents, but you are free to use whatever you want. We support Claude Code, Codex CLI, and Kiro CLI, but you can edit the vault with any AI you want. We provide an AGENTS file for your agents to figure out. - ⌨️ **Keyboard-first** — Tolaria is designed for power-users who want to use keyboard as much as possible. A lot of how we designed the Editor and the Command Palette is based on this. - 💪 **Built from real use** — Tolaria was created for manage my personal vault of 10,000+ notes, and I use it every day. Every feature exists because it solved a real problem. diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 4c2cd518a..cc86ad548 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -706,7 +706,7 @@ interface Settings { release_channel: string | null // null = stable default, "alpha" = every-push prerelease feed theme_mode: 'light' | 'dark' | null ui_language: 'en' | 'zh-Hans' | null - default_ai_agent: 'claude_code' | 'codex' | null + default_ai_agent: 'claude_code' | 'codex' | 'kiro' | null } ``` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5af72b3be..502c9e0b1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -98,7 +98,7 @@ flowchart LR | Build | Vite | 7.3.1 | | Backend language | Rust (edition 2021) | 1.77.2 | | Frontmatter parsing | gray_matter | 0.2 | -| AI (agent panel) | CLI agent adapters (Claude Code + Codex) | - | +| AI (agent panel) | CLI agent adapters (Claude Code + Codex + Kiro) | - | | Search | Keyword (walkdir-based file scan) | - | | Localization | App-owned dictionary (`src/lib/i18n.ts`) | English fallback + `zh-Hans` | | MCP | @modelcontextprotocol/sdk | 1.0 | @@ -218,7 +218,7 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too 1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgents.ts`) — streaming UI with reasoning blocks, tool action cards, response display, onboarding, and default-agent selection 2. **Backend** (`ai_agents.rs`) — normalizes agent availability and streaming, dispatching to per-agent adapters -3. **Agent adapters** — Claude Code still uses `claude_cli.rs`; Codex runs through `codex exec --json` with the CLI's normal approval / sandbox defaults +3. **Agent adapters** — Claude Code still uses `claude_cli.rs`; Codex runs through `codex exec --json` with the CLI's normal approval / sandbox defaults; Kiro runs through `kiro-cli chat --no-interactive --trust-all-tools` with ANSI stripping 4. **MCP Integration** — Claude receives the generated MCP config file path, while Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. @@ -236,7 +236,7 @@ sequenceDiagram U->>FE: sendMessage(text, references) FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs) FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath}) - R->>R: pick adapter for claude_code or codex + R->>R: pick adapter for claude_code, codex, or kiro R->>C: spawn agent with MCP-enabled config loop Normalized stream @@ -282,7 +282,7 @@ Token budget: 60% of 180k context limit (~108k tokens max). Active note gets pri ### Authentication -Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed. Tolaria does not store model-provider API keys in app settings. +Each CLI agent authenticates itself outside Tolaria. Claude Code uses its existing CLI login; Codex surfaces a friendly prompt to run `codex login` when needed; Kiro uses `kiro-cli login`. Tolaria does not store model-provider API keys in app settings. ## MCP Server @@ -463,7 +463,7 @@ When an opened folder is not yet a git repo, Tolaria shows a dismissible Git set When the user enables Git later, `init_git_repo` runs `git init`, ensures Tolaria's default `.gitignore`, stages the vault, and writes the initial `Initial vault setup` commit. That app-managed setup commit explicitly disables commit signing for the single command so inherited global or local `commit.gpgsign` preferences cannot strand onboarding when GPG is missing or misconfigured. Later `git_commit` calls honor the user's signing configuration first, then retry the same app-managed commit once with `commit.gpgsign=false` only when Git reports a signing-helper failure, so working GPG/SSH signing setups continue to sign while broken GPG setups do not create repeated opaque commit failures. -Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code and Codex are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch. +Once a vault is ready, `useAiAgentsOnboarding` can show a one-time `AiAgentsOnboardingPrompt`. That prompt reads `useAiAgentsStatus` so first launch surfaces whether Claude Code, Codex, and Kiro are installed, offers per-agent install links when they are missing, and stores local dismissal so the prompt does not repeat on every launch. `useGettingStartedClone` reuses the same parent-folder semantics for the status-bar / command-palette clone action, and `Toast` is rendered through the AI-agents onboarding gate so the resolved destination path stays visible right after a successful clone. diff --git a/src-tauri/src/ai_agents.rs b/src-tauri/src/ai_agents.rs index 0cfe69c3e..c5634e25d 100644 --- a/src-tauri/src/ai_agents.rs +++ b/src-tauri/src/ai_agents.rs @@ -1,3 +1,4 @@ +use regex::Regex; use serde::{Deserialize, Serialize}; use std::io::BufRead; use std::path::{Path, PathBuf}; @@ -8,6 +9,7 @@ use std::process::{Command, Stdio}; pub enum AiAgentId { ClaudeCode, Codex, + Kiro, } #[derive(Debug, Clone, Serialize)] @@ -20,6 +22,7 @@ pub struct AiAgentAvailability { pub struct AiAgentsStatus { pub claude_code: AiAgentAvailability, pub codex: AiAgentAvailability, + pub kiro: AiAgentAvailability, } #[derive(Debug, Clone, Serialize)] @@ -63,6 +66,7 @@ pub fn get_ai_agents_status() -> AiAgentsStatus { AiAgentsStatus { claude_code: availability_from_claude(), codex: availability_from_codex(), + kiro: availability_from_kiro(), } } @@ -84,6 +88,7 @@ where }) } AiAgentId::Codex => run_codex_agent_stream(request, emit), + AiAgentId::Kiro => run_kiro_agent_stream(request, emit), } } @@ -122,11 +127,11 @@ fn version_for_binary(binary: &PathBuf) -> Option { } fn find_codex_binary() -> Result { - if let Some(binary) = find_codex_binary_on_path() { + if let Some(binary) = find_binary_on_path("codex") { return Ok(binary); } - if let Some(binary) = find_codex_binary_in_user_shell() { + if let Some(binary) = find_binary_in_user_shell("codex") { return Ok(binary); } @@ -137,21 +142,6 @@ fn find_codex_binary() -> Result { Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into()) } -fn find_codex_binary_on_path() -> Option { - Command::new("which") - .arg("codex") - .output() - .ok() - .and_then(|output| path_from_successful_output(&output)) -} - -fn find_codex_binary_in_user_shell() -> Option { - user_shell_candidates() - .into_iter() - .filter(|shell| shell.exists()) - .find_map(|shell| command_path_from_shell(&shell, "codex")) -} - fn user_shell_candidates() -> Vec { let mut shells = Vec::new(); if let Some(shell) = std::env::var_os("SHELL") { @@ -217,6 +207,233 @@ fn find_existing_binary(candidates: Vec) -> Option { candidates.into_iter().find(|candidate| candidate.exists()) } +fn availability_from_kiro() -> AiAgentAvailability { + let binary = match find_kiro_binary() { + Ok(binary) => binary, + Err(_) => { + return AiAgentAvailability { + installed: false, + version: None, + } + } + }; + + AiAgentAvailability { + installed: true, + version: version_for_binary(&binary), + } +} + +fn find_kiro_binary() -> Result { + if let Some(binary) = find_binary_on_path("kiro-cli") { + return Ok(binary); + } + + if let Some(binary) = find_binary_in_user_shell("kiro-cli") { + return Ok(binary); + } + + if let Some(binary) = find_existing_binary(kiro_binary_candidates()) { + return Ok(binary); + } + + Err("Kiro CLI not found. Install it: https://kiro.dev/docs/cli".into()) +} + +fn find_binary_on_path(name: &str) -> Option { + Command::new("which") + .arg(name) + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) +} + +fn find_binary_in_user_shell(name: &str) -> Option { + user_shell_candidates() + .into_iter() + .filter(|shell| shell.exists()) + .find_map(|shell| command_path_from_shell(&shell, name)) +} + +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"), + ] +} + +fn run_kiro_agent_stream(request: AiAgentStreamRequest, mut emit: F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let binary = find_kiro_binary()?; + ensure_kiro_mcp_config(&request.vault_path)?; + let prompt = build_kiro_prompt(&request); + + let mut command = Command::new(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(|error| format!("Failed to spawn kiro-cli: {error}"))?; + + if let Some(mut stdin) = child.stdin.take() { + use std::io::Write; + stdin + .write_all(prompt.as_bytes()) + .map_err(|e| format!("Failed to write prompt to stdin: {e}"))?; + } + + let session_id = format!("kiro-{}", std::process::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(line) => line, + Err(error) => { + emit(AiAgentStreamEvent::Error { + message: format!("Read error: {error}"), + }); + 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 status = child + .wait() + .map_err(|error| format!("Wait failed: {error}"))?; + if !status.success() { + emit(AiAgentStreamEvent::Error { + message: format_kiro_error(stderr_output, status.to_string()), + }); + } + + emit(AiAgentStreamEvent::Done); + + Ok(session_id) +} + +fn ensure_kiro_mcp_config(vault_path: &str) -> Result<(), String> { + let mcp_server = crate::mcp::mcp_server_dir()?.join("index.js"); + let mcp_server_path = mcp_server + .to_str() + .ok_or("Invalid MCP server path")?; + write_kiro_mcp_json(vault_path, mcp_server_path) +} + +fn write_kiro_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 build_kiro_prompt(request: &AiAgentStreamRequest) -> String { + match request + .system_prompt + .as_ref() + .map(|prompt| prompt.trim()) + .filter(|prompt| !prompt.is_empty()) + { + Some(system_prompt) => format!( + "System instructions:\n{system_prompt}\n\nUser request:\n{}", + request.message + ), + None => request.message.clone(), + } +} + +fn format_kiro_error(stderr_output: String, status: String) -> String { + let lower = stderr_output.to_ascii_lowercase(); + if ["auth", "login", "sign in"] + .iter() + .any(|pattern| lower.contains(pattern)) + { + return "Kiro CLI is not authenticated. Run `kiro-cli login` in your terminal.".into(); + } + + if stderr_output.trim().is_empty() { + format!("kiro-cli exited with status {status}") + } else { + stderr_output.lines().take(3).collect::>().join("\n") + } +} + fn run_codex_agent_stream(request: AiAgentStreamRequest, mut emit: F) -> Result where F: FnMut(AiAgentStreamEvent), @@ -443,6 +660,7 @@ mod tests { let status = get_ai_agents_status(); assert!(matches!(status.claude_code.installed, true | false)); assert!(matches!(status.codex.installed, true | false)); + assert!(matches!(status.kiro.installed, true | false)); } #[test] @@ -592,4 +810,113 @@ mod tests { assert!(matches!(mapped, Some(AiAgentStreamEvent::Done))); } + + #[test] + fn build_kiro_prompt_keeps_system_prompt_first() { + let prompt = build_kiro_prompt(&AiAgentStreamRequest { + agent: AiAgentId::Kiro, + message: "Rename the note".into(), + system_prompt: Some("Be concise".into()), + vault_path: "/tmp/vault".into(), + }); + + assert!(prompt.starts_with("System instructions:\nBe concise")); + assert!(prompt.contains("User request:\nRename the note")); + } + + #[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 kiro_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"), + PathBuf::from("/opt/homebrew/bin/kiro-cli"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn write_kiro_mcp_json_creates_config_with_tolaria_server() { + let dir = tempfile::tempdir().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + + write_kiro_mcp_json(vault_path, "/opt/mcp/index.js").unwrap(); + + let config_path = dir.path().join(".kiro/settings/mcp.json"); + assert!(config_path.exists()); + + 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); + } + + #[test] + fn write_kiro_mcp_json_merges_preserving_existing_servers() { + let dir = tempfile::tempdir().unwrap(); + let vault_path = dir.path().to_str().unwrap(); + + // Pre-create config with another server + 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_kiro_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(); + // tolaria entry updated + assert_eq!(content["mcpServers"]["tolaria"]["args"][0], "/new/index.js"); + // existing server preserved + assert_eq!(content["mcpServers"]["other"]["command"], "python"); + } + + #[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_detects_login_errors() { + let result = format_kiro_error("Please login first".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(), "exit code: 1".into()); + assert_eq!(result, "kiro-cli exited with status exit code: 1"); + } + + #[test] + fn format_kiro_error_truncates_long_stderr_to_three_lines() { + let stderr = "line1\nline2\nline3\nline4\nline5".into(); + let result = format_kiro_error(stderr, "1".into()); + assert_eq!(result, "line1\nline2\nline3"); + } } diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index 6a36a1e1a..90aae89a7 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -50,7 +50,7 @@ pub fn effective_release_channel(value: Option<&str>) -> &'static str { pub fn normalize_default_ai_agent(value: Option<&str>) -> Option { match value.map(|candidate| candidate.trim().to_ascii_lowercase()) { - Some(agent) if agent == "claude_code" || agent == "codex" => Some(agent), + Some(agent) if agent == "claude_code" || agent == "codex" || agent == "kiro" => Some(agent), _ => None, } } @@ -348,6 +348,15 @@ mod tests { assert!(loaded.default_ai_agent.is_none()); } + #[test] + fn test_kiro_is_valid_default_ai_agent() { + let loaded = save_and_reload(Settings { + default_ai_agent: Some("kiro".to_string()), + ..Default::default() + }); + assert_eq!(loaded.default_ai_agent.as_deref(), Some("kiro")); + } + #[test] fn test_invalid_theme_mode_is_filtered() { let loaded = save_and_reload(Settings { diff --git a/src/components/AiAgentsOnboardingPrompt.test.tsx b/src/components/AiAgentsOnboardingPrompt.test.tsx index 6791cdf53..ee0d9078e 100644 --- a/src/components/AiAgentsOnboardingPrompt.test.tsx +++ b/src/components/AiAgentsOnboardingPrompt.test.tsx @@ -23,6 +23,7 @@ describe('AiAgentsOnboardingPrompt', () => { statuses={{ claude_code: { status: 'installed', version: '1.0.20' }, codex: { status: 'missing', version: null }, + kiro: { status: 'missing', version: null }, }} onContinue={vi.fn()} />, @@ -39,6 +40,7 @@ describe('AiAgentsOnboardingPrompt', () => { statuses={{ claude_code: { status: 'missing', version: null }, codex: { status: 'missing', version: null }, + kiro: { status: 'missing', version: null }, }} onContinue={vi.fn()} />, @@ -58,6 +60,7 @@ describe('AiAgentsOnboardingPrompt', () => { statuses={{ claude_code: { status: 'missing', version: null }, codex: { status: 'missing', version: null }, + kiro: { status: 'missing', version: null }, }} onContinue={vi.fn()} />, @@ -76,6 +79,7 @@ describe('AiAgentsOnboardingPrompt', () => { statuses={{ claude_code: { status: 'installed', version: '1.0.20' }, codex: { status: 'missing', version: null }, + kiro: { status: 'missing', version: null }, }} onContinue={vi.fn()} />, diff --git a/src/components/StatusBar.test.tsx b/src/components/StatusBar.test.tsx index 378383c5a..4d2186b50 100644 --- a/src/components/StatusBar.test.tsx +++ b/src/components/StatusBar.test.tsx @@ -19,6 +19,7 @@ const vaults: VaultOption[] = [ const installedAiAgentsStatus = { claude_code: { status: 'installed' as const, version: '1.0.20' }, codex: { status: 'installed' as const, version: '0.37.0' }, + kiro: { status: 'missing' as const, version: null }, } const DEFAULT_WINDOW_WIDTH = 1280 diff --git a/src/components/status-bar/AiAgentsBadge.test.tsx b/src/components/status-bar/AiAgentsBadge.test.tsx index 5b8b40e00..e2cd2d50d 100644 --- a/src/components/status-bar/AiAgentsBadge.test.tsx +++ b/src/components/status-bar/AiAgentsBadge.test.tsx @@ -12,6 +12,7 @@ vi.mock('../../utils/url', async () => { const installedStatuses = { claude_code: { status: 'installed' as const, version: '1.0.20' }, codex: { status: 'installed' as const, version: '0.37.0' }, + kiro: { status: 'missing' as const, version: null }, } function render(ui: ReactElement) { diff --git a/src/hooks/useAiAgentPreferences.test.ts b/src/hooks/useAiAgentPreferences.test.ts index 2bc31c254..2dfa58fe7 100644 --- a/src/hooks/useAiAgentPreferences.test.ts +++ b/src/hooks/useAiAgentPreferences.test.ts @@ -15,6 +15,7 @@ const settings = { const aiAgentsStatus = { claude_code: { status: 'installed' as const, version: '1.0.20' }, codex: { status: 'missing' as const, version: null }, + kiro: { status: 'missing' as const, version: null }, } describe('useAiAgentPreferences', () => { @@ -63,6 +64,7 @@ describe('useAiAgentPreferences', () => { aiAgentsStatus: { claude_code: { status: 'missing', version: null }, codex: { status: 'missing', version: null }, + kiro: { status: 'missing', version: null }, }, })) diff --git a/src/hooks/useAiAgentsStatus.test.ts b/src/hooks/useAiAgentsStatus.test.ts index ff76425fe..8ff2d8c58 100644 --- a/src/hooks/useAiAgentsStatus.test.ts +++ b/src/hooks/useAiAgentsStatus.test.ts @@ -24,6 +24,7 @@ describe('useAiAgentsStatus', () => { return Promise.resolve({ claude_code: { installed: true, version: '1.0.20' }, codex: { installed: false, version: null }, + kiro: { installed: false, version: null }, }) } return Promise.resolve(null) @@ -33,10 +34,12 @@ describe('useAiAgentsStatus', () => { expect(result.current.claude_code.status).toBe('checking') expect(result.current.codex.status).toBe('checking') + expect(result.current.kiro.status).toBe('checking') await waitFor(() => { expect(result.current.claude_code).toEqual({ status: 'installed', version: '1.0.20' }) expect(result.current.codex).toEqual({ status: 'missing', version: null }) + expect(result.current.kiro).toEqual({ status: 'missing', version: null }) }) }) @@ -48,6 +51,7 @@ describe('useAiAgentsStatus', () => { await waitFor(() => { expect(result.current.claude_code.status).toBe('missing') expect(result.current.codex.status).toBe('missing') + expect(result.current.kiro.status).toBe('missing') }) }) }) diff --git a/src/hooks/useCommandRegistry.test.ts b/src/hooks/useCommandRegistry.test.ts index a8919f2ec..2f73c141f 100644 --- a/src/hooks/useCommandRegistry.test.ts +++ b/src/hooks/useCommandRegistry.test.ts @@ -724,6 +724,7 @@ describe('reload-vault command', () => { aiAgentsStatus: { claude_code: { status: 'installed', version: '1.0.20' }, codex: { status: 'installed', version: '0.37.0' }, + kiro: { status: 'missing', version: null }, }, selectedAiAgent: 'claude_code', onSetDefaultAiAgent, @@ -744,6 +745,7 @@ describe('reload-vault command', () => { aiAgentsStatus: { claude_code: { status: 'installed', version: '1.0.20' }, codex: { status: 'missing', version: null }, + kiro: { status: 'missing', version: null }, }, selectedAiAgent: 'claude_code', onSetDefaultAiAgent: vi.fn(), diff --git a/src/lib/aiAgents.test.ts b/src/lib/aiAgents.test.ts index c0d489f5e..9a4df0f94 100644 --- a/src/lib/aiAgents.test.ts +++ b/src/lib/aiAgents.test.ts @@ -10,6 +10,7 @@ describe('aiAgents helpers', () => { it('normalizes stored agent ids', () => { expect(normalizeStoredAiAgent('claude_code')).toBe('claude_code') expect(normalizeStoredAiAgent('codex')).toBe('codex') + expect(normalizeStoredAiAgent('kiro')).toBe('kiro') expect(normalizeStoredAiAgent('cursor')).toBeNull() }) @@ -22,14 +23,17 @@ describe('aiAgents helpers', () => { const statuses = normalizeAiAgentsStatus({ claude_code: { installed: true, version: '1.0.20' }, codex: { installed: false, version: null }, + kiro: { installed: true, version: '2.0.0' }, }) expect(statuses.claude_code).toEqual({ status: 'installed', version: '1.0.20' }) expect(statuses.codex).toEqual({ status: 'missing', version: null }) + expect(statuses.kiro).toEqual({ status: 'installed', version: '2.0.0' }) }) it('cycles between the supported agents', () => { expect(getNextAiAgentId('claude_code')).toBe('codex') - expect(getNextAiAgentId('codex')).toBe('claude_code') + expect(getNextAiAgentId('codex')).toBe('kiro') + expect(getNextAiAgentId('kiro')).toBe('claude_code') }) }) diff --git a/src/lib/aiAgents.ts b/src/lib/aiAgents.ts index 2abd885d9..451c1a3de 100644 --- a/src/lib/aiAgents.ts +++ b/src/lib/aiAgents.ts @@ -1,4 +1,4 @@ -export type AiAgentId = 'claude_code' | 'codex' +export type AiAgentId = 'claude_code' | 'codex' | 'kiro' export type AiAgentStatus = 'checking' | 'installed' | 'missing' @@ -10,6 +10,7 @@ export interface AiAgentAvailability { export interface AiAgentsStatus { claude_code: AiAgentAvailability codex: AiAgentAvailability + kiro: AiAgentAvailability } export interface AiAgentDefinition { @@ -34,6 +35,12 @@ export const AI_AGENT_DEFINITIONS: readonly AiAgentDefinition[] = [ shortLabel: 'Codex', installUrl: 'https://developers.openai.com/codex/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 { @@ -44,6 +51,7 @@ export function createCheckingAiAgentsStatus(): AiAgentsStatus { return { claude_code: createAiAgentAvailability(), codex: createAiAgentAvailability(), + kiro: createAiAgentAvailability(), } } @@ -51,11 +59,12 @@ export function createMissingAiAgentsStatus(): AiAgentsStatus { return { claude_code: createAiAgentAvailability('missing'), codex: createAiAgentAvailability('missing'), + kiro: createAiAgentAvailability('missing'), } } export function normalizeStoredAiAgent(value: string | null | undefined): AiAgentId | null { - if (value === 'claude_code' || value === 'codex') return value + if (value === 'claude_code' || value === 'codex' || value === 'kiro') return value return null } @@ -79,6 +88,7 @@ export function normalizeAiAgentsStatus(payload: Partial any> = { get_ai_agents_status: () => ({ claude_code: { installed: false, version: null }, codex: { installed: false, version: null }, + kiro: { installed: false, version: null }, }), get_vault_ai_guidance_status: () => ({ ...mockVaultAiGuidanceStatus }), restore_vault_ai_guidance: () => {