diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 78d1eb9a..c7a273d5 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -813,7 +813,7 @@ Vault guidance is intentionally short and vault-specific. General Tolaria produc `useAiAgentsOnboarding(enabled)` adds a separate first-launch agent step: - Reads a local dismissal flag for the AI agents prompt (with a legacy fallback to the older Claude-only key) - Only shows after vault onboarding has already resolved to a ready state -- Uses `get_ai_agents_status`, whose backend treats the app process path, login-shell path, and supported local/toolchain/app install locations, including nvm-managed Node installs plus Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources +- Uses `get_ai_agents_status`, whose backend checks Claude Code, Codex, OpenCode, Pi, Gemini, and Kiro by treating the app process path, login-shell path, and supported local/toolchain/app install locations, including nvm-managed Node installs plus Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources - Persists dismissal locally once the user continues ### Remote Git Operations @@ -851,7 +851,7 @@ interface Settings { sidebar_type_pluralization_enabled: boolean | null // null = default true ai_features_enabled: boolean | null // null = default true git_enabled: boolean | null // null = default true - default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | null + default_ai_agent: 'claude_code' | 'codex' | 'opencode' | 'pi' | 'gemini' | 'kiro' | null default_ai_target: string | null // "agent:codex" or "model:/" ai_model_providers: AiModelProvider[] | null hide_gitignored_files: boolean | null // null = default true diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d93b97a3..3d3a8803 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -142,7 +142,7 @@ The note list opportunistically preloads visible and adjacent markdown/text entr | Backend language | Rust (edition 2021) | 1.77.2 | | Frontmatter parsing | gray_matter | 0.2 | | Filesystem watcher | notify | 6.1 | -| AI (agent panel) | CLI agent adapters (Claude Code + Codex + OpenCode + Pi + Gemini) | - | +| AI (agent panel) | CLI agent adapters (Claude Code + Codex + OpenCode + Pi + Gemini + Kiro) | - | | Search | Keyword (walkdir-based file scan) | - | | Localization | App-owned runtime + JSON catalogs (`src/lib/i18n.ts`, `src/lib/locales/*.json`, `lara.yaml`) | English fallback + Lara CLI sync | | MCP | @modelcontextprotocol/sdk | 1.0 | @@ -181,7 +181,7 @@ flowchart TD end subgraph EXT["External Services"] - CCLI["Claude / Codex / OpenCode / Pi / Gemini CLI\n(agent subprocesses)"] + CCLI["Claude / Codex / OpenCode / Pi / Gemini / Kiro CLI\n(agent subprocesses)"] MCP["MCP Server\n(ws://9710, 9711)"] GCLI["git CLI\n(system executable)"] REMOTE["Git remotes\n(GitHub/GitLab/Gitea/etc.)"] @@ -265,8 +265,8 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too 1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts` + `aiTargets.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-target selection, bundled-docs prompt injection, and the per-vault Safe / Power User permission mode shown in the panel header for coding agents 2. **Backend orchestration** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters 3. **Shared runtime scaffold** (`cli_agent_runtime.rs`) — owns the common request shape, prompt wrapping, JSON-line subprocess lifecycle, normalized error/done handling, version probing, and Tolaria MCP server path resolution used by app-managed CLI agents -4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. Codex, OpenCode, Pi, and Gemini all launch from the active vault cwd with closed stdin and transient MCP config. Pi seeds its transient agent directory from the user's Pi agent directory before merging Tolaria MCP, so app-managed runs keep standalone Pi provider/auth settings. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags. -5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides using Tolaria's resolved Node path plus `VAULT_PATH` and `WS_UI_PORT`, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter` after copying and merging the user's Pi agent config, and Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH` +4. **Agent adapters** — Shared prompts are mode-aware on every turn, including turns with note context snapshots: Vault Safe tells agents not to use or advertise shell, while Power User tells shell-capable agents to keep local commands scoped to the active vault. Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools only, while Power User adds Bash to the available tools and pre-approves Bash with `--allowedTools` without using dangerous permission-bypass flags. Codex runtime specifics live in `codex_cli.rs`; Safe runs `codex --sandbox read-only --ask-for-approval untrusted exec --json`, while Power User runs `codex --sandbox workspace-write --ask-for-approval never exec --json` so shell execution stays enabled across repeated turns. OpenCode runs through `opencode run --format json` with transient permissions: Safe denies bash and external directories, while Power User allows bash but still denies external directories. Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`; both modes currently share the same transient MCP config and the prompt does not promise shell for Pi Power User. Gemini runs through `gemini --output-format stream-json --prompt` so assistant message chunks, tool calls, and final errors are mapped from the CLI event stream instead of relying on a buffered `response` field. Gemini Safe uses `auto_edit` plus `tools.exclude=["run_shell_command"]`; Power User intentionally uses `yolo` against a trusted transient Tolaria MCP entry. Kiro runs through `kiro-cli chat --no-interactive --trust-all-tools`, streams line-oriented stdout, drains stderr concurrently, and writes prompt content through stdin to avoid OS argument length limits. Codex, OpenCode, Pi, Gemini, and Kiro all launch from the active vault cwd with transient MCP config. Pi seeds its transient agent directory from the user's Pi agent directory before merging Tolaria MCP, so app-managed runs keep standalone Pi provider/auth settings. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags. +5. **MCP Integration** — Claude receives the generated MCP config file path, Codex receives the same Tolaria MCP server via transient `-c mcp_servers.tolaria.*` config overrides using Tolaria's resolved Node path plus `VAULT_PATH` and `WS_UI_PORT`, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter` after copying and merging the user's Pi agent config, Gemini receives it through a temporary settings file pointed at by `GEMINI_CLI_SYSTEM_SETTINGS_PATH`, and Kiro receives it through `.kiro/settings/mcp.json` in the active vault. 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, nvm-managed Node installs, 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. App-managed CLI spawns also expand the active vault path before using it as the subprocess working directory, then extend the child process `PATH` with the resolved binary directory plus those common toolchain directories, which lets GUI-launched macOS sessions run Homebrew/npm shims and their `node`-backed MCP subprocesses even when Finder/Dock did not inherit a terminal shell path. @@ -285,11 +285,11 @@ sequenceDiagram U->>FE: sendMessage(text, references) FE->>FE: buildContextSnapshot(activeNote, linkedNotes, openTabs) FE->>R: invoke('stream_ai_agent', {agent, message, systemPrompt, vaultPath, permissionMode}) - R->>R: pick adapter for claude_code, codex, opencode, or pi + R->>R: pick adapter for claude_code, codex, opencode, pi, gemini, or kiro R->>C: spawn agent with MCP-enabled config loop Normalized stream - C-->>R: Claude NDJSON, Codex JSONL, OpenCode JSON, Pi JSON, or Gemini JSONL events + C-->>R: Claude NDJSON, Codex JSONL, OpenCode JSON, Pi JSON, Gemini JSONL, or Kiro text events R-->>FE: emit("ai-agent-stream", event) alt TextDelta FE->>FE: accumulate response (revealed on Done) @@ -529,7 +529,7 @@ When an opened folder is not yet a git repo, Tolaria can show a Git setup dialog 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. Before app-managed setup, remote-connection, manual/automatic, and conflict-resolution commits, Tolaria ensures the vault has local `user.name` / `user.email` values, falling back to `Tolaria ` when the vault has no local Git identity yet. 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, Codex, OpenCode, Pi, and Gemini CLI 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, OpenCode, Pi, Gemini, and Kiro CLI 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. @@ -793,9 +793,9 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: |---------|-------------| | `stream_claude_chat` | Claude CLI chat mode (streaming) | | `check_claude_cli` | Check if Claude CLI is available | -| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi + Gemini availability | +| `get_ai_agents_status` | Check Claude Code + Codex + OpenCode + Pi + Gemini + Kiro availability | | `get_agent_docs_path` | Resolve the bundled local Tolaria docs folder used in AI-agent system prompts | -| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, Pi, or Gemini through the normalized agent event layer | +| `stream_ai_agent` | Stream Claude Code, Codex, OpenCode, Pi, Gemini, or Kiro through the normalized agent event layer | | `register_mcp_tools` | Register vault-neutral MCP in Claude/Gemini/Cursor/OpenCode/generic config | | `remove_mcp_tools` | Remove Tolaria's MCP entry from Claude/Gemini/Cursor/OpenCode/generic config | | `check_mcp_status` | Check whether Tolaria's durable MCP entry is registered in Claude/Gemini/Cursor/OpenCode/generic config | @@ -1092,7 +1092,7 @@ Desktop-only modules gated at the crate level: Desktop-only features gated at the function level in `commands/`: - Git operations (commit, pull, push, status, history, diff, conflicts) - Clone-by-URL via system git (`clone_repo`) -- CLI AI agent streaming (Claude, Codex, OpenCode, Pi, Gemini) +- CLI AI agent streaming (Claude, Codex, OpenCode, Pi, Gemini, Kiro) - MCP registration and status - Menu state updates diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index e27612ad..edc9496c 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -151,7 +151,7 @@ tolaria/ │ │ ├── useNoteRename.ts # Note renaming + wikilink updates │ │ ├── useCliAiAgent.ts # Selected AI agent state + normalized session pipeline │ │ ├── aiAgentPermissionMode.ts # Safe/Power User mode normalization + labels -│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi/Gemini availability polling +│ │ ├── useAiAgentsStatus.ts # Claude/Codex/OpenCode/Pi/Gemini/Kiro availability polling │ │ ├── useAiAgentPreferences.ts # Default-agent persistence + cycling │ │ ├── useAiActivity.ts # MCP UI bridge listener │ │ ├── useAutoSync.ts # Auto git pull/push @@ -231,6 +231,7 @@ tolaria/ │ │ ├── claude_cli.rs # Claude CLI adapter │ │ ├── codex_cli.rs # Codex CLI adapter │ │ ├── pi_cli.rs # Pi CLI adapter +│ │ ├── kiro_cli.rs # Kiro CLI adapter │ │ ├── mcp.rs # MCP server lifecycle + explicit config registration/removal │ │ ├── app_updater.rs # Alpha/stable updater metadata resolution │ │ ├── settings.rs # App settings persistence @@ -307,7 +308,7 @@ tolaria/ | `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. | | `src-tauri/src/ai_agents.rs` | CLI-agent request normalization, availability aggregation, adapter dispatch, and Claude event mapping. | | `src-tauri/src/cli_agent_runtime.rs` | Shared CLI-agent request shape, prompt wrapping, JSON subprocess lifecycle, version probing, and MCP path helpers. | -| `src-tauri/src/claude_cli.rs`, `src-tauri/src/codex_cli.rs`, `src-tauri/src/opencode_cli.rs`, `src-tauri/src/pi_cli.rs`, `src-tauri/src/gemini_cli.rs` | Per-agent command, config, discovery, and JSON event adapters. | +| `src-tauri/src/claude_cli.rs`, `src-tauri/src/codex_cli.rs`, `src-tauri/src/opencode_cli.rs`, `src-tauri/src/pi_cli.rs`, `src-tauri/src/gemini_cli.rs`, `src-tauri/src/kiro_cli.rs` | Per-agent command, config, discovery, and event adapters. | | `src-tauri/src/app_updater.rs` | Desktop updater bridge — resolves alpha/stable manifests and streams install progress. | ### Editor @@ -467,7 +468,7 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/.spec.ts 3. **Tool action display**: Edit `src/components/AiActionCard.tsx` 4. **Permission-mode UI and request plumbing**: Edit `src/lib/aiAgentPermissionMode.ts`, `src/components/AiPanel*.tsx`, `src/hooks/useCliAiAgent.ts`, and `src/utils/streamAiAgent.ts` 5. **Shared CLI runtime behavior**: Edit `src-tauri/src/cli_agent_runtime.rs` for process lifecycle, prompt wrapping, version probing, and common Tolaria MCP path handling. -6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `gemini_*`). Keep Codex Safe on `read-only` + `untrusted` and Codex Power User on active-vault `workspace-write` + `never`, keep Pi and Gemini on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode. Pi's transient agent directory must be seeded from the user's existing Pi agent directory before Tolaria MCP is merged so standalone provider/auth setup keeps working. Gemini Power User intentionally uses Gemini's `yolo` mode per ADR-0103. +6. **Agent-specific arguments/events**: Edit the per-agent adapter modules (`claude_cli.rs`, `codex_cli.rs`, `opencode_*`, `pi_*`, `gemini_*`, `kiro_*`). Keep Codex Safe on `read-only` + `untrusted` and Codex Power User on active-vault `workspace-write` + `never`, keep Pi, Gemini, and Kiro on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode. Pi's transient agent directory must be seeded from the user's existing Pi agent directory before Tolaria MCP is merged so standalone provider/auth setup keeps working. Gemini Power User intentionally uses Gemini's `yolo` mode per ADR-0103. Kiro receives prompt content over stdin and writes Tolaria MCP config into `.kiro/settings/mcp.json` in the active vault. ### Work with external MCP setup diff --git a/src-tauri/src/ai_agents.rs b/src-tauri/src/ai_agents.rs index b3cf9fa7..927892c9 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)] @@ -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( + request: AiAgentStreamRequest, + permission_mode: AiAgentPermissionMode, + emit: F, +) -> Result +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 { diff --git a/src-tauri/src/cli_agent_runtime.rs b/src-tauri/src/cli_agent_runtime.rs index 99c5b623..497cf2fb 100644 --- a/src-tauri/src/cli_agent_runtime.rs +++ b/src-tauri/src/cli_agent_runtime.rs @@ -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, + 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_cli.rs b/src-tauri/src/kiro_cli.rs new file mode 100644 index 00000000..6dce9e52 --- /dev/null +++ b/src-tauri/src/kiro_cli.rs @@ -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(request: AgentStreamRequest, mut emit: F) -> Result +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 { + 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, +) -> std::thread::JoinHandle> { + 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(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 { + 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>) -> Option { + 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) { + 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 = 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::>() + .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"); + } +} diff --git a/src-tauri/src/kiro_discovery.rs b/src-tauri/src/kiro_discovery.rs new file mode 100644 index 00000000..438a0ebb --- /dev/null +++ b/src-tauri/src/kiro_discovery.rs @@ -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 { + crate::cli_agent_runtime::find_cli_binary( + "kiro-cli", + kiro_binary_candidates(), + "Kiro CLI", + "https://kiro.dev/docs/cli", + ) +} + +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() + ); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 78b3dd5f..ad65c457 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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; diff --git a/src-tauri/src/settings.rs b/src-tauri/src/settings.rs index b7259722..71b9589c 100644 --- a/src-tauri/src/settings.rs +++ b/src-tauri/src/settings.rs @@ -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"]; diff --git a/src/components/AiAgentsOnboardingPrompt.tsx b/src/components/AiAgentsOnboardingPrompt.tsx index b550d2b3..158a43cc 100644 --- a/src/components/AiAgentsOnboardingPrompt.tsx +++ b/src/components/AiAgentsOnboardingPrompt.tsx @@ -1,6 +1,7 @@ import { ArrowUpRight, CheckCircle as CheckCircle2, CircleNotch as Loader2, Cloud, HardDrive, Robot as Bot, Terminal } from '@phosphor-icons/react' import { AI_AGENT_DEFINITIONS, + getAiAgentAvailability, getAiAgentDefinition, hasAnyInstalledAiAgent, isAiAgentsStatusChecking, @@ -81,7 +82,7 @@ function AgentStatusList({ statuses }: { statuses: AiAgentsStatus }) { return (
{AI_AGENT_DEFINITIONS.map((definition) => { - const status = statuses[definition.id] + const status = getAiAgentAvailability(statuses, definition.id) const ready = status.status === 'installed' return (
statuses[definition.id].status === 'missing') + const showLegacyClaudeCompatibility = getAiAgentAvailability(statuses, 'claude_code').status !== 'installed' + const missingAgents = AI_AGENT_DEFINITIONS.filter((definition) => getAiAgentAvailability(statuses, definition.id).status === 'missing') return ( { const agentOptions = AI_AGENT_DEFINITIONS.map((definition) => { - const status = aiAgentsStatus[definition.id] + const status = getAiAgentAvailability(aiAgentsStatus, definition.id) const suffix = status.status === 'installed' ? ` (${t('settings.aiAgents.installed')}${status.version ? ` ${status.version}` : ''})` : ` (${t('settings.aiAgents.missing')})` @@ -1110,7 +1111,7 @@ function AiAgentsInstalledSection({
{t('settings.aiAgents.installedDescription')}
{AI_AGENT_DEFINITIONS.map((definition) => { - const status = aiAgentsStatus[definition.id] + const status = getAiAgentAvailability(aiAgentsStatus, definition.id) const installed = status.status === 'installed' return (
@@ -1133,7 +1134,7 @@ function AiAgentsInstalledSection({ function renderDefaultAiAgentSummary(defaultAiAgent: AiAgentId, aiAgentsStatus: AiAgentsStatus, t: Translate): string { const definition = getAiAgentDefinition(defaultAiAgent) - const status = Reflect.get(aiAgentsStatus, defaultAiAgent) as AiAgentsStatus[AiAgentId] + const status = getAiAgentAvailability(aiAgentsStatus, defaultAiAgent) if (status.status === 'installed') { return t('settings.aiAgents.ready', { agent: definition.label, diff --git a/src/components/status-bar/AiAgentsBadge.tsx b/src/components/status-bar/AiAgentsBadge.tsx index ca3387df..6fdcbde8 100644 --- a/src/components/status-bar/AiAgentsBadge.tsx +++ b/src/components/status-bar/AiAgentsBadge.tsx @@ -2,6 +2,7 @@ import { CaretUpDown as ChevronsUpDown, Sparkle, Warning as AlertTriangle } from import { Button } from '@/components/ui/button' import { AI_AGENT_DEFINITIONS, + getAiAgentAvailability, getAiAgentDefinition, hasAnyInstalledAiAgent, isAiAgentInstalled, @@ -65,7 +66,7 @@ function badgeTooltip( if (!isAiAgentInstalled(statuses, defaultAgent)) { return translate(locale, 'status.ai.selectedMissing', { agent: definition.label }) } - const version = (Reflect.get(statuses, defaultAgent) as AiAgentsStatus[AiAgentId]).version + const version = getAiAgentAvailability(statuses, defaultAgent).version const base = translate(locale, 'status.ai.defaultAgent', { agent: definition.label, version: version ? ` ${version}` : '' }) if (!guidanceSummary) return base if (vaultAiGuidanceNeedsRestore(guidanceStatus!)) { @@ -101,7 +102,7 @@ function menuHeading(locale: AppLocale, selectedTarget: AiTarget, selectedAgentR } function statusText(statuses: AiAgentsStatus, definition: AiAgentDefinition): string { - const version = statuses[definition.id].version + const version = getAiAgentAvailability(statuses, definition.id).version return version ? `${definition.label} ${version}` : definition.label } diff --git a/src/hooks/useAiAgentPreferences.ts b/src/hooks/useAiAgentPreferences.ts index cf607847..eb0b3b60 100644 --- a/src/hooks/useAiAgentPreferences.ts +++ b/src/hooks/useAiAgentPreferences.ts @@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react' import { isTauri } from '../mock-tauri' import { getAiAgentDefinition, + getAiAgentAvailability, getNextAiAgentId, type AiAgentReadiness, type AiAgentId, @@ -33,7 +34,7 @@ function getDefaultAiTargetReadiness( if (defaultTarget.kind === 'api_model') return 'ready' if (!isTauri()) return 'ready' - const status = aiAgentsStatus[defaultTarget.agent].status + const status = getAiAgentAvailability(aiAgentsStatus, defaultTarget.agent).status if (status === 'checking') return 'checking' return status === 'installed' ? 'ready' : 'missing' } diff --git a/src/lib/aiAgents.test.ts b/src/lib/aiAgents.test.ts index 2f64057a..5ce6043d 100644 --- a/src/lib/aiAgents.test.ts +++ b/src/lib/aiAgents.test.ts @@ -13,6 +13,7 @@ describe('aiAgents helpers', () => { expect(normalizeStoredAiAgent('opencode')).toBe('opencode') expect(normalizeStoredAiAgent('pi')).toBe('pi') expect(normalizeStoredAiAgent('gemini')).toBe('gemini') + expect(normalizeStoredAiAgent('kiro')).toBe('kiro') expect(normalizeStoredAiAgent('cursor')).toBeNull() }) @@ -42,6 +43,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 df304bff..33b730c8 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,30 +50,33 @@ 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 { return { status, version } } +function createAiAgentsStatus(status: AiAgentStatus): AiAgentsStatus { + return Object.fromEntries( + AI_AGENT_DEFINITIONS.map((definition) => [ + definition.id, + createAiAgentAvailability(status), + ]), + ) as AiAgentsStatus +} + export function createCheckingAiAgentsStatus(): AiAgentsStatus { - return { - claude_code: createAiAgentAvailability(), - codex: createAiAgentAvailability(), - opencode: createAiAgentAvailability(), - pi: createAiAgentAvailability(), - gemini: createAiAgentAvailability(), - } + return createAiAgentsStatus('checking') } export function createMissingAiAgentsStatus(): AiAgentsStatus { - return { - claude_code: createAiAgentAvailability('missing'), - codex: createAiAgentAvailability('missing'), - opencode: createAiAgentAvailability('missing'), - pi: createAiAgentAvailability('missing'), - gemini: createAiAgentAvailability('missing'), - } + return createAiAgentsStatus('missing') } export function normalizeStoredAiAgent(value: string | null | undefined): AiAgentId | null { @@ -104,18 +107,23 @@ export function normalizeAiAgentsStatus(payload: Partial statuses[definition.id].status === 'checking') +export function getAiAgentAvailability(statuses: Partial, agent: AiAgentId): AiAgentAvailability { + return statuses[agent] ?? createAiAgentAvailability('missing') } -export function isAiAgentInstalled(statuses: AiAgentsStatus, agent: AiAgentId): boolean { - return (Reflect.get(statuses, agent) as AiAgentAvailability).status === 'installed' +export function isAiAgentsStatusChecking(statuses: Partial): boolean { + return AI_AGENT_DEFINITIONS.some((definition) => getAiAgentAvailability(statuses, definition.id).status === 'checking') } -export function hasAnyInstalledAiAgent(statuses: AiAgentsStatus): boolean { +export function isAiAgentInstalled(statuses: Partial, agent: AiAgentId): boolean { + return getAiAgentAvailability(statuses, agent).status === 'installed' +} + +export function hasAnyInstalledAiAgent(statuses: Partial): boolean { return AI_AGENT_DEFINITIONS.some((definition) => isAiAgentInstalled(statuses, definition.id)) } diff --git a/src/lib/aiTargets.ts b/src/lib/aiTargets.ts index b21a57e8..2cead324 100644 --- a/src/lib/aiTargets.ts +++ b/src/lib/aiTargets.ts @@ -1,5 +1,6 @@ import { DEFAULT_AI_AGENT, + getAiAgentAvailability, getAiAgentDefinition, normalizeStoredAiAgent, type AiAgentId, @@ -185,5 +186,5 @@ export function isLocalAiProvider(provider: AiModelProvider): boolean { export function aiTargetReady(target: AiTarget, statuses: AiAgentsStatus): boolean { if (target.kind === 'api_model') return true - return statuses[target.agent].status === 'installed' + return getAiAgentAvailability(statuses, target.agent).status === 'installed' } diff --git a/src/mock-tauri/mock-handlers.ts b/src/mock-tauri/mock-handlers.ts index be775dfa..e64f76ff 100644 --- a/src/mock-tauri/mock-handlers.ts +++ b/src/mock-tauri/mock-handlers.ts @@ -465,6 +465,7 @@ export const mockHandlers: Record 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 }),