Merge pull request #572 from oksusucha/feat/add-kiro-cli-agent

feat: add Kiro CLI as AI agent

Co-authored-by: oksusucha <sol4877@gmail.com>
This commit is contained in:
lucaronin
2026-05-18 13:55:43 +02:00
17 changed files with 559 additions and 47 deletions

View File

@@ -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:<provider>/<model>"
ai_model_providers: AiModelProvider[] | null
hide_gitignored_files: boolean | null // null = default true

View File

@@ -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 <vault@tolaria.md>` 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

View File

@@ -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/<slug>.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

View File

@@ -8,6 +8,7 @@ pub enum AiAgentId {
Opencode,
Pi,
Gemini,
Kiro,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
@@ -31,6 +32,7 @@ pub struct AiAgentsStatus {
pub opencode: AiAgentAvailability,
pub pi: AiAgentAvailability,
pub gemini: AiAgentAvailability,
pub kiro: AiAgentAvailability,
}
#[derive(Debug, Clone, Serialize)]
@@ -86,6 +88,7 @@ pub fn get_ai_agents_status() -> AiAgentsStatus {
opencode: crate::opencode_cli::check_cli(),
pi: crate::pi_cli::check_cli(),
gemini: crate::gemini_cli::check_cli(),
kiro: crate::kiro_cli::check_cli(),
}
}
@@ -149,9 +152,28 @@ where
};
crate::gemini_cli::run_agent_stream(mapped, emit)
}
AiAgentId::Kiro => run_kiro_agent_stream(request, permission_mode, emit),
}
}
fn run_kiro_agent_stream<F>(
request: AiAgentStreamRequest,
permission_mode: AiAgentPermissionMode,
emit: F,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let mapped = crate::cli_agent_runtime::AgentStreamRequest {
message: request.message,
system_prompt: request.system_prompt,
vault_path: request.vault_path,
vault_paths: request.vault_paths,
permission_mode,
};
crate::kiro_cli::run_agent_stream(mapped, emit)
}
fn availability_from_claude() -> AiAgentAvailability {
let status = crate::claude_cli::check_cli();
AiAgentAvailability {

View File

@@ -455,6 +455,97 @@ where
Ok(run.session_id)
}
/// Shared binary discovery: look up a CLI command by name using PATH, login shell, then candidates.
pub(crate) fn find_cli_binary(
name: &str,
candidates: Vec<PathBuf>,
label: &str,
install_hint: &str,
) -> Result<PathBuf, String> {
if let Some(binary) = find_binary_on_path(name) {
return Ok(binary);
}
if let Some(binary) = find_binary_in_user_shell(name) {
return Ok(binary);
}
if let Some(binary) = find_executable_binary_candidate(candidates, label)? {
return Ok(binary);
}
Err(format!("{label} not found. Install it: {install_hint}"))
}
fn find_binary_on_path(name: &str) -> Option<PathBuf> {
let cmd = if cfg!(windows) { "where" } else { "which" };
crate::hidden_command(cmd)
.arg(name)
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn find_binary_in_user_shell(name: &str) -> Option<PathBuf> {
shell_candidates()
.into_iter()
.filter(|shell| shell.exists())
.find_map(|shell| command_path_from_shell(&shell, name))
}
fn shell_candidates() -> Vec<PathBuf> {
let mut shells = Vec::new();
if let Some(shell) = std::env::var_os("SHELL") {
if !shell.is_empty() {
shells.push(PathBuf::from(shell));
}
}
shells.push(PathBuf::from("/bin/zsh"));
shells.push(PathBuf::from("/bin/bash"));
shells
}
fn command_path_from_shell(shell: &Path, command: &str) -> Option<PathBuf> {
crate::hidden_command(shell)
.arg("-lc")
.arg(format!("command -v {command}"))
.output()
.ok()
.and_then(|output| path_from_successful_output(&output))
}
fn path_from_successful_output(output: &std::process::Output) -> Option<PathBuf> {
if output.status.success() {
first_existing_path(&String::from_utf8_lossy(&output.stdout))
} else {
None
}
}
pub(crate) fn first_existing_path(stdout: &str) -> Option<PathBuf> {
stdout.lines().find_map(|line| {
let trimmed = line.trim();
if trimmed.is_empty() {
return None;
}
let candidate = PathBuf::from(trimmed);
candidate.exists().then_some(candidate)
})
}
/// Shared check_cli pattern for CLI agents.
pub(crate) fn check_cli_availability(
find_binary: impl FnOnce() -> Result<PathBuf, String>,
) -> crate::ai_agents::AiAgentAvailability {
match find_binary() {
Ok(binary) => crate::ai_agents::AiAgentAvailability {
installed: true,
version: version_for_binary(&binary),
},
Err(_) => crate::ai_agents::AiAgentAvailability {
installed: false,
version: None,
},
}
}
#[cfg(test)]
mod tests {
use super::*;

320
src-tauri/src/kiro_cli.rs Normal file
View File

@@ -0,0 +1,320 @@
use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent};
use crate::cli_agent_runtime::AgentStreamRequest;
use regex::Regex;
use std::io::{BufRead, Read, Write};
use std::path::Path;
use std::process::{ChildStderr, ChildStdin, ChildStdout, Stdio};
struct KiroMcpConfig<'a> {
vault_path: &'a str,
vault_paths: &'a [String],
mcp_server_path: &'a str,
}
struct KiroError<'a> {
stderr_output: &'a str,
status: String,
}
pub fn check_cli() -> AiAgentAvailability {
crate::kiro_discovery::check_cli()
}
pub fn run_agent_stream<F>(request: AgentStreamRequest, mut emit: F) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
{
let binary = crate::kiro_discovery::find_binary()?;
ensure_mcp_config(&request)?;
let prompt =
crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref());
let mut child = spawn_kiro_process(&binary, Path::new(&request.vault_path))?;
let prompt_handle = write_prompt_async(
child.stdin.take().ok_or("No stdin handle")?,
prompt.into_bytes(),
);
let stderr_handle = read_stderr_async(child.stderr.take().ok_or("No stderr handle")?);
let session_id = generate_session_id();
emit(AiAgentStreamEvent::Init {
session_id: session_id.clone(),
});
stream_stdout(child.stdout.take().ok_or("No stdout handle")?, &mut emit);
let mut stderr_output = stderr_handle.join().unwrap_or_default();
if let Some(error) = prompt_write_error(prompt_handle) {
append_stderr_line(&mut stderr_output, error);
}
let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?;
if !status.success() {
emit(AiAgentStreamEvent::Error {
message: format_kiro_error(KiroError {
stderr_output: &stderr_output,
status: status.to_string(),
}),
});
}
emit(AiAgentStreamEvent::Done);
Ok(session_id)
}
fn spawn_kiro_process(binary: &Path, vault_path: &Path) -> Result<std::process::Child, String> {
let mut command = crate::hidden_command(binary);
crate::cli_agent_runtime::configure_agent_command_environment(&mut command, binary);
command
.arg("chat")
.arg("--no-interactive")
.arg("--trust-all-tools")
.current_dir(vault_path)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
command
.spawn()
.map_err(|e| format!("Failed to spawn kiro-cli: {e}"))
}
fn write_prompt_async(
mut stdin: ChildStdin,
prompt: Vec<u8>,
) -> std::thread::JoinHandle<Result<(), String>> {
std::thread::spawn(move || {
stdin
.write_all(&prompt)
.map_err(|e| format!("Failed to write kiro-cli stdin: {e}"))
})
}
fn generate_session_id() -> String {
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0);
format!("kiro-{}-{}", std::process::id(), ts)
}
fn stream_stdout<F>(stdout: ChildStdout, emit: &mut F)
where
F: FnMut(AiAgentStreamEvent),
{
let reader = std::io::BufReader::new(stdout);
for line in reader.lines() {
match line {
Ok(l) if !l.is_empty() => {
emit(AiAgentStreamEvent::TextDelta {
text: format!("{}\n", strip_ansi_codes(&l)),
});
}
Ok(_) => {
emit(AiAgentStreamEvent::TextDelta {
text: "\n".to_string(),
});
}
Err(e) => {
emit(AiAgentStreamEvent::Error {
message: format!("Read error: {e}"),
});
break;
}
}
}
}
fn read_stderr_async(mut stderr: ChildStderr) -> std::thread::JoinHandle<String> {
std::thread::spawn(move || {
let mut output = String::new();
let _ = stderr.read_to_string(&mut output);
output
})
}
fn prompt_write_error(handle: std::thread::JoinHandle<Result<(), String>>) -> Option<String> {
match handle.join() {
Ok(Ok(())) => None,
Ok(Err(error)) => Some(error),
Err(_) => Some("Failed to write kiro-cli stdin: writer thread panicked".into()),
}
}
fn append_stderr_line(stderr_output: &mut String, line: impl AsRef<str>) {
if !stderr_output.is_empty() {
stderr_output.push('\n');
}
stderr_output.push_str(line.as_ref());
}
fn ensure_mcp_config(request: &AgentStreamRequest) -> Result<(), String> {
let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?;
write_mcp_json(KiroMcpConfig {
vault_path: &request.vault_path,
vault_paths: &request.vault_paths,
mcp_server_path: &mcp_server_path,
})
}
fn write_mcp_json(config: KiroMcpConfig<'_>) -> Result<(), String> {
let config_dir = Path::new(config.vault_path).join(".kiro").join("settings");
std::fs::create_dir_all(&config_dir)
.map_err(|e| format!("Failed to create .kiro/settings: {e}"))?;
let config_path = config_dir.join("mcp.json");
let mut json_config: serde_json::Value = std::fs::read_to_string(&config_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| serde_json::json!({}));
let servers = json_config
.as_object_mut()
.ok_or("Invalid mcp.json: not an object")?
.entry("mcpServers")
.or_insert_with(|| serde_json::json!({}));
let active_vault_paths =
crate::cli_agent_runtime::active_vault_paths_json(config.vault_path, config.vault_paths);
servers["tolaria"] = serde_json::json!({
"command": "node",
"args": [config.mcp_server_path],
"env": {
"VAULT_PATH": config.vault_path,
"VAULT_PATHS": active_vault_paths,
"WS_UI_PORT": "9711"
},
"disabled": false
});
std::fs::write(
&config_path,
serde_json::to_string_pretty(&json_config)
.map_err(|e| format!("JSON serialize error: {e}"))?,
)
.map_err(|e| format!("Failed to write mcp.json: {e}"))?;
Ok(())
}
fn strip_ansi_codes(input: &str) -> String {
static RE: std::sync::OnceLock<Regex> = std::sync::OnceLock::new();
let re = RE.get_or_init(|| Regex::new(r"\x1b\[[0-?]*[ -/]*[@-~]").unwrap());
re.replace_all(input, "").to_string()
}
fn format_kiro_error(error: KiroError<'_>) -> String {
if is_auth_error(error.stderr_output) {
return "Kiro CLI is not authenticated. Run `kiro-cli login` in your terminal to sign in."
.into();
}
if error.stderr_output.trim().is_empty() {
format!("kiro-cli exited with status {}", error.status)
} else {
error
.stderr_output
.lines()
.take(3)
.collect::<Vec<_>>()
.join("\n")
}
}
fn is_auth_error(stderr_output: &str) -> bool {
let lower = stderr_output.to_ascii_lowercase();
["auth", "login", "token"]
.iter()
.any(|needle| lower.contains(needle))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strip_ansi_codes_removes_terminal_colors() {
assert_eq!(
strip_ansi_codes("\x1b[38;5;141m> \x1b[0mHello! \x1b[2K"),
"> Hello! "
);
assert_eq!(strip_ansi_codes("plain text"), "plain text");
}
#[test]
fn format_kiro_error_detects_auth_errors() {
let result = format_kiro_error(KiroError {
stderr_output: "Error: auth token expired",
status: "1".into(),
});
assert!(result.contains("kiro-cli login"));
}
#[test]
fn format_kiro_error_returns_status_for_empty_stderr() {
let result = format_kiro_error(KiroError {
stderr_output: "",
status: "1".into(),
});
assert!(result.contains("status 1"));
}
#[test]
fn write_mcp_json_creates_config() {
let dir = tempfile::tempdir().unwrap();
let vault_path = dir.path().to_str().unwrap();
write_mcp_json(KiroMcpConfig {
vault_path,
vault_paths: &["/other/vault".into(), vault_path.into()],
mcp_server_path: "/opt/mcp/index.js",
})
.unwrap();
let config_path = dir.path().join(".kiro/settings/mcp.json");
let content: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
assert_eq!(content["mcpServers"]["tolaria"]["command"], "node");
assert_eq!(
content["mcpServers"]["tolaria"]["args"][0],
"/opt/mcp/index.js"
);
assert_eq!(
content["mcpServers"]["tolaria"]["env"]["VAULT_PATH"],
vault_path
);
assert_eq!(
content["mcpServers"]["tolaria"]["env"]["VAULT_PATHS"],
serde_json::json!(serde_json::to_string(&vec![vault_path, "/other/vault"]).unwrap())
);
assert_eq!(
content["mcpServers"]["tolaria"]["env"]["WS_UI_PORT"],
"9711"
);
}
#[test]
fn write_mcp_json_merges_preserving_existing_servers() {
let dir = tempfile::tempdir().unwrap();
let vault_path = dir.path().to_str().unwrap();
let config_dir = dir.path().join(".kiro/settings");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::write(
config_dir.join("mcp.json"),
r#"{"mcpServers":{"other":{"command":"python","args":["server.py"]}}}"#,
)
.unwrap();
write_mcp_json(KiroMcpConfig {
vault_path,
vault_paths: &[],
mcp_server_path: "/new/index.js",
})
.unwrap();
let content: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(dir.path().join(".kiro/settings/mcp.json")).unwrap(),
)
.unwrap();
assert_eq!(content["mcpServers"]["tolaria"]["args"][0], "/new/index.js");
assert_eq!(content["mcpServers"]["other"]["command"], "python");
}
}

View File

@@ -0,0 +1,59 @@
use crate::ai_agents::AiAgentAvailability;
use std::path::{Path, PathBuf};
pub(crate) fn check_cli() -> AiAgentAvailability {
crate::cli_agent_runtime::check_cli_availability(find_binary)
}
pub(crate) fn find_binary() -> Result<PathBuf, String> {
crate::cli_agent_runtime::find_cli_binary(
"kiro-cli",
kiro_binary_candidates(),
"Kiro CLI",
"https://kiro.dev/docs/cli",
)
}
fn kiro_binary_candidates() -> Vec<PathBuf> {
dirs::home_dir()
.map(|home| kiro_binary_candidates_for_home(&home))
.unwrap_or_default()
}
fn kiro_binary_candidates_for_home(home: &Path) -> Vec<PathBuf> {
vec![
home.join(".local/bin/kiro-cli"),
home.join(".kiro/bin/kiro-cli"),
home.join(".local/share/mise/shims/kiro-cli"),
home.join(".asdf/shims/kiro-cli"),
home.join(".npm-global/bin/kiro-cli"),
home.join(".npm/bin/kiro-cli"),
home.join(".bun/bin/kiro-cli"),
PathBuf::from("/usr/local/bin/kiro-cli"),
PathBuf::from("/opt/homebrew/bin/kiro-cli"),
]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn binary_candidates_include_supported_installs() {
let home = PathBuf::from("/Users/alex");
let candidates = kiro_binary_candidates_for_home(&home);
let expected = [
home.join(".local/bin/kiro-cli"),
home.join(".kiro/bin/kiro-cli"),
home.join(".npm-global/bin/kiro-cli"),
PathBuf::from("/opt/homebrew/bin/kiro-cli"),
];
for candidate in expected {
assert!(
candidates.contains(&candidate),
"missing {}",
candidate.display()
);
}
}
}

View File

@@ -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;

View File

@@ -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"];

View File

@@ -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 (
<div className="space-y-3">
{AI_AGENT_DEFINITIONS.map((definition) => {
const status = statuses[definition.id]
const status = getAiAgentAvailability(statuses, definition.id)
const ready = status.status === 'installed'
return (
<div
@@ -113,8 +114,8 @@ export function AiAgentsOnboardingPrompt({
onContinue,
}: AiAgentsOnboardingPromptProps) {
const copy = getPromptCopy(statuses)
const showLegacyClaudeCompatibility = statuses.claude_code.status !== 'installed'
const missingAgents = AI_AGENT_DEFINITIONS.filter((definition) => 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 (
<OnboardingShell

View File

@@ -2,6 +2,7 @@ import { Copy, Cube, Monitor, Moon, Sun, X } from '@phosphor-icons/react'
import {
AI_AGENT_DEFINITIONS,
createMissingAiAgentsStatus,
getAiAgentAvailability,
getAiAgentDefinition,
resolveDefaultAiAgent,
type AiAgentId,
@@ -939,7 +940,7 @@ function buildDefaultAiTargetOptions(
t: Translate,
): Array<{ value: string; label: string }> {
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({
<div className="mt-1 text-xs leading-5 text-muted-foreground">{t('settings.aiAgents.installedDescription')}</div>
<div className="mt-3 grid grid-cols-2 gap-2">
{AI_AGENT_DEFINITIONS.map((definition) => {
const status = aiAgentsStatus[definition.id]
const status = getAiAgentAvailability(aiAgentsStatus, definition.id)
const installed = status.status === 'installed'
return (
<div key={definition.id} className="rounded-md border border-border bg-background px-3 py-2">
@@ -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,

View File

@@ -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
}

View File

@@ -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'
}

View File

@@ -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')
})
})

View File

@@ -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<Record<AiAgentId, { ins
opencode: normalizeAvailability(payload?.opencode),
pi: normalizeAvailability(payload?.pi),
gemini: normalizeAvailability(payload?.gemini),
kiro: normalizeAvailability(payload?.kiro),
}
}
export function isAiAgentsStatusChecking(statuses: AiAgentsStatus): boolean {
return AI_AGENT_DEFINITIONS.some((definition) => statuses[definition.id].status === 'checking')
export function getAiAgentAvailability(statuses: Partial<AiAgentsStatus>, 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<AiAgentsStatus>): boolean {
return AI_AGENT_DEFINITIONS.some((definition) => getAiAgentAvailability(statuses, definition.id).status === 'checking')
}
export function hasAnyInstalledAiAgent(statuses: AiAgentsStatus): boolean {
export function isAiAgentInstalled(statuses: Partial<AiAgentsStatus>, agent: AiAgentId): boolean {
return getAiAgentAvailability(statuses, agent).status === 'installed'
}
export function hasAnyInstalledAiAgent(statuses: Partial<AiAgentsStatus>): boolean {
return AI_AGENT_DEFINITIONS.some((definition) => isAiAgentInstalled(statuses, definition.id))
}

View File

@@ -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'
}

View File

@@ -465,6 +465,7 @@ export const mockHandlers: Record<string, (args: any) => 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 }),