From a08cee79653597a0499baf7c51338d11e89abee8 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Wed, 29 Apr 2026 04:47:16 +0200 Subject: [PATCH] refactor: share cli agent runtime scaffold --- docs/ARCHITECTURE.md | 12 +- docs/GETTING-STARTED.md | 18 +- .../0093-shared-cli-agent-runtime-adapters.md | 35 + docs/adr/README.md | 1 + src-tauri/src/ai_agents.rs | 695 +----------------- src-tauri/src/claude_cli.rs | 79 +- src-tauri/src/cli_agent_runtime.rs | 186 +++++ src-tauri/src/codex_cli.rs | 607 +++++++++++++++ src-tauri/src/lib.rs | 2 + src-tauri/src/opencode_cli.rs | 62 +- src-tauri/src/opencode_config.rs | 19 +- src-tauri/src/opencode_discovery.rs | 11 +- src-tauri/src/opencode_events.rs | 18 +- src-tauri/src/pi_cli.rs | 62 +- src-tauri/src/pi_config.rs | 19 +- src-tauri/src/pi_discovery.rs | 11 +- src-tauri/src/pi_events.rs | 18 +- 17 files changed, 913 insertions(+), 942 deletions(-) create mode 100644 docs/adr/0093-shared-cli-agent-runtime-adapters.md create mode 100644 src-tauri/src/cli_agent_runtime.rs create mode 100644 src-tauri/src/codex_cli.rs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3c0d2adc..a576ffa5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -225,9 +225,10 @@ Notes can be opened in separate Tauri windows for focused editing. Secondary win Full agent mode — spawns the selected local CLI agent as a subprocess with tool access and MCP vault integration. 1. **Frontend** (`AiPanel` + `useCliAiAgent` + `aiAgentSession.ts` + `aiAgents.ts`) — one normalized session lifecycle for message state, reasoning blocks, tool action cards, response display, onboarding, default-agent selection, and the per-vault Safe / Power User permission mode shown in the panel header -2. **Backend** (`ai_agents.rs`) — normalizes agent availability, streaming, and the request permission mode before dispatching to per-agent adapters -3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools, while Power User adds Bash without using dangerous bypass flags. Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json` in both modes. 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. OpenCode and Pi both launch from the active vault cwd with closed stdin and transient MCP config. All app-launched paths use hidden Windows launches and avoid dangerous permission-bypass flags. -4. **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, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, and Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter` +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** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, and a scoped tool list: Safe enables file/search/edit tools, while Power User adds Bash without using dangerous bypass flags. Codex runtime specifics live in `codex_cli.rs` and run through `codex --sandbox workspace-write --ask-for-approval never exec --json` in both modes. 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. OpenCode and Pi both launch from the active vault cwd with closed stdin and transient MCP config. 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, OpenCode receives it through `OPENCODE_CONFIG_CONTENT`, and Pi receives it through a temporary `PI_CODING_AGENT_DIR/mcp.json` consumed by `pi-mcp-adapter` 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. @@ -638,8 +639,9 @@ The vault backend (`src-tauri/src/vault/`) is split into focused submodules: | `frontmatter/` | YAML frontmatter read/write (`mod.rs`, `yaml.rs`, `ops.rs`) | | `git/` | Git operations (`commit.rs`, `status.rs`, `history.rs`, `conflict.rs`, `remote.rs`, `pulse.rs`, `clone.rs`, `connect.rs`) | | `search.rs` | Keyword search — walkdir-based vault file scan with Gitignored-content visibility filtering | -| `ai_agents.rs` | Shared CLI-agent detection, stream normalization, and adapter dispatch | -| `claude_cli.rs` | Claude Code subprocess spawning + NDJSON stream parsing | +| `ai_agents.rs` | CLI-agent request normalization and adapter dispatch | +| `cli_agent_runtime.rs` | Shared CLI-agent request, prompt, subprocess, version, and MCP path helpers | +| `claude_cli.rs`, `codex_cli.rs`, `opencode_cli.rs` | Claude Code, Codex, and OpenCode command/config/event adapters | | `pi_cli.rs`, `pi_config.rs`, `pi_discovery.rs`, `pi_events.rs` | Pi subprocess launch, transient MCP adapter config, discovery, and JSON stream parsing | | `mcp.rs` | MCP server spawning + explicit config registration/removal | | `commands/` | Tauri command handlers (split into submodules) | diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 34c5062f..37dedfca 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -189,9 +189,11 @@ tolaria/ │ │ │ ├── conflict.rs, remote.rs, pulse.rs │ │ ├── telemetry.rs # Sentry init + path scrubber │ │ ├── search.rs # Keyword search (walkdir-based) -│ │ ├── ai_agents.rs # Shared CLI-agent detection + stream adapters -│ │ ├── claude_cli.rs # Claude CLI subprocess management -│ │ ├── pi_cli.rs # Pi CLI subprocess management +│ │ ├── ai_agents.rs # CLI-agent request normalization + adapter dispatch +│ │ ├── cli_agent_runtime.rs # Shared CLI-agent runtime process/prompt/MCP helpers +│ │ ├── claude_cli.rs # Claude CLI adapter +│ │ ├── codex_cli.rs # Codex CLI adapter +│ │ ├── pi_cli.rs # Pi CLI adapter │ │ ├── mcp.rs # MCP server lifecycle + explicit config registration/removal │ │ ├── app_updater.rs # Alpha/stable updater endpoint selection │ │ ├── settings.rs # App settings persistence @@ -262,9 +264,9 @@ tolaria/ | `src-tauri/src/frontmatter/ops.rs` | YAML manipulation — how properties are updated/deleted in files. | | `src-tauri/src/git/` | All git operations (clone, commit, pull, push, conflicts, pulse, add-remote). | | `src-tauri/src/search.rs` | Keyword search — scans vault files with walkdir. | -| `src-tauri/src/ai_agents.rs` | Shared CLI-agent availability checks, adapter dispatch, stream normalization, and permission-mode request normalization. | -| `src-tauri/src/claude_cli.rs` | Claude CLI subprocess spawning, permission-mode tool mapping, and NDJSON stream parsing. | -| `src-tauri/src/pi_cli.rs` | Pi subprocess spawning through JSON mode and transient MCP adapter config. | +| `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` | Per-agent command, config, discovery, and JSON event adapters. | | `src-tauri/src/app_updater.rs` | Desktop updater bridge — selects alpha/stable manifests and streams install progress. | ### Editor @@ -423,8 +425,8 @@ BASE_URL="http://localhost:5173" npx playwright test tests/smoke/.spec.ts 2. **Context building**: Edit `src/utils/ai-context.ts` for what data is sent to the agent 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. **Claude CLI arguments**: Edit `src-tauri/src/claude_cli.rs` (`run_agent_stream()`; keep app-managed launches on strict Tolaria MCP config, `acceptEdits`, and the scoped file/search tool list unless the permission mode explicitly broadens it) -6. **Shared agent adapters / Codex/Pi args**: Edit `src-tauri/src/ai_agents.rs` plus the per-agent adapter modules (keep Codex sandboxed with active-vault `workspace-write`, keep Pi on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode) +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_*`). Keep Codex sandboxed with active-vault `workspace-write`, keep Pi on transient MCP config, and do not use dangerous permission bypasses unless an ADR explicitly designs a new mode. ### Work with external MCP setup diff --git a/docs/adr/0093-shared-cli-agent-runtime-adapters.md b/docs/adr/0093-shared-cli-agent-runtime-adapters.md new file mode 100644 index 00000000..274625f2 --- /dev/null +++ b/docs/adr/0093-shared-cli-agent-runtime-adapters.md @@ -0,0 +1,35 @@ +--- +type: ADR +id: "0093" +title: "Shared CLI agent runtime adapters" +status: active +date: 2026-04-29 +--- + +## Context + +Tolaria supports Claude Code, Codex, OpenCode, and Pi as local CLI agents in the AI panel. Each agent has different command-line arguments, configuration shape, and JSON event schema, but the Rust backend had grown repeated runtime plumbing around those differences: request shapes, prompt wrapping, subprocess launch, stdout JSON reading, stderr capture, exit handling, done events, version probing, and Tolaria MCP server path resolution. + +That duplication made small runtime fixes expensive because they had to be repeated across several adapter files. It also kept Codex-specific command and event mapping inside `ai_agents.rs`, making the top-level module both an orchestrator and a bespoke adapter. + +## Decision + +**Tolaria uses `cli_agent_runtime.rs` as the shared runtime scaffold for app-managed CLI agents, while `ai_agents.rs` only normalizes and dispatches requests to per-agent adapter modules.** + +The shared scaffold owns the common agent request shape, system/user prompt wrapping, JSON-line process lifecycle, normalized error/done handling for `AiAgentStreamEvent` adapters, version probing, and Tolaria MCP server path resolution. Per-agent modules keep the provider-specific pieces: binary discovery candidates, command arguments, transient config shape, authentication error wording, and JSON event mapping. + +Codex now lives in `codex_cli.rs`, matching the Claude, OpenCode, and Pi adapter boundary. `ai_agents.rs` remains the Tauri-facing orchestrator that chooses an adapter and maps Claude's legacy event enum into the normalized event stream. + +## Options Considered + +- **Shared runtime scaffold with thin adapters** (chosen): reduces repeated process lifecycle code without hiding provider-specific command/config/event behavior. +- **One trait object per agent**: more uniform on paper, but adds indirection without removing much current complexity. +- **Leave each adapter self-contained**: keeps local readability for a single file, but new process, prompt, and MCP fixes continue to land in parallel. +- **Fully generic event mapping**: over-abstracts the JSON schemas and makes provider-specific edge cases harder to test. + +## Consequences + +- Runtime lifecycle fixes should usually start in `cli_agent_runtime.rs`. +- New agent adapters should use the shared request/prompt/process helpers and keep only command, config, discovery, and event mapping local. +- `ai_agents.rs` should not grow provider-specific runtime code again; it should normalize the frontend request, dispatch to an adapter, and map any legacy event shape. +- The shared scaffold deliberately does not erase provider differences. Authentication messages, permission semantics, and transient config formats remain adapter-owned and must stay covered by adapter tests. diff --git a/docs/adr/README.md b/docs/adr/README.md index fac6e836..0ea6bc7b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -145,3 +145,4 @@ proposed → active → superseded | [0090](0090-pi-cli-agent-adapter.md) | Pi CLI agent adapter | active | | [0091](0091-gemini-cli-external-ai-setup.md) | Gemini CLI external AI setup | active | | [0092](0092-vault-ai-agent-permission-modes.md) | Vault-scoped AI agent permission modes | active | +| [0093](0093-shared-cli-agent-runtime-adapters.md) | Shared CLI agent runtime adapters | active | diff --git a/src-tauri/src/ai_agents.rs b/src-tauri/src/ai_agents.rs index 9b0bebae..76650cb7 100644 --- a/src-tauri/src/ai_agents.rs +++ b/src-tauri/src/ai_agents.rs @@ -1,7 +1,4 @@ use serde::{Deserialize, Serialize}; -use std::io::BufRead; -use std::path::{Path, PathBuf}; -use std::process::Stdio; #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -81,9 +78,9 @@ impl AiAgentStreamRequest { pub fn get_ai_agents_status() -> AiAgentsStatus { AiAgentsStatus { claude_code: availability_from_claude(), - codex: availability_from_codex(), - opencode: availability_from_opencode(), - pi: availability_from_pi(), + codex: crate::codex_cli::check_cli(), + opencode: crate::opencode_cli::check_cli(), + pi: crate::pi_cli::check_cli(), } } @@ -106,7 +103,15 @@ where } }) } - AiAgentId::Codex => run_codex_agent_stream(request, emit), + AiAgentId::Codex => { + let mapped = crate::codex_cli::AgentStreamRequest { + message: request.message, + system_prompt: request.system_prompt, + vault_path: request.vault_path, + permission_mode, + }; + crate::codex_cli::run_agent_stream(mapped, emit) + } AiAgentId::Opencode => { let mapped = crate::opencode_cli::AgentStreamRequest { message: request.message, @@ -136,379 +141,6 @@ fn availability_from_claude() -> AiAgentAvailability { } } -fn availability_from_codex() -> AiAgentAvailability { - let binary = match find_codex_binary() { - Ok(binary) => binary, - Err(_) => { - return AiAgentAvailability { - installed: false, - version: None, - } - } - }; - - AiAgentAvailability { - installed: true, - version: version_for_binary(&binary), - } -} - -fn availability_from_opencode() -> AiAgentAvailability { - crate::opencode_cli::check_cli() -} - -fn availability_from_pi() -> AiAgentAvailability { - crate::pi_cli::check_cli() -} - -fn version_for_binary(binary: &PathBuf) -> Option { - crate::hidden_command(binary) - .arg("--version") - .output() - .ok() - .filter(|output| output.status.success()) - .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) -} - -fn find_codex_binary() -> Result { - if let Some(binary) = find_codex_binary_on_path() { - return Ok(binary); - } - - if let Some(binary) = find_codex_binary_in_user_shell() { - return Ok(binary); - } - - if let Some(binary) = find_existing_binary(codex_binary_candidates()) { - return Ok(binary); - } - - Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into()) -} - -fn find_codex_binary_on_path() -> Option { - crate::hidden_command("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") { - if !shell.is_empty() { - shells.push(PathBuf::from(shell)); - } - } - shells.push(PathBuf::from("/bin/zsh")); - shells.push(PathBuf::from("/bin/bash")); - shells -} - -fn command_path_from_shell(shell: &Path, command: &str) -> Option { - crate::hidden_command(shell) - .arg("-lc") - .arg(format!("command -v {command}")) - .output() - .ok() - .and_then(|output| path_from_successful_output(&output)) -} - -fn path_from_successful_output(output: &std::process::Output) -> Option { - if output.status.success() { - first_existing_path(&String::from_utf8_lossy(&output.stdout)) - } else { - None - } -} - -fn first_existing_path(stdout: &str) -> Option { - stdout.lines().find_map(|line| { - let trimmed = line.trim(); - if trimmed.is_empty() { - return None; - } - let candidate = PathBuf::from(trimmed); - candidate.exists().then_some(candidate) - }) -} - -fn codex_binary_candidates() -> Vec { - dirs::home_dir() - .map(|home| codex_binary_candidates_for_home(&home)) - .unwrap_or_default() -} - -fn codex_binary_candidates_for_home(home: &Path) -> Vec { - let mut candidates = vec![ - home.join(".local/bin/codex"), - home.join(".codex/bin/codex"), - home.join(".local/share/mise/shims/codex"), - home.join(".asdf/shims/codex"), - home.join(".npm-global/bin/codex"), - home.join(".npm/bin/codex"), - home.join(".bun/bin/codex"), - PathBuf::from("/usr/local/bin/codex"), - PathBuf::from("/opt/homebrew/bin/codex"), - PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"), - ]; - candidates.extend(nvm_node_binary_candidates_for_home(home, "codex")); - candidates -} - -fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec { - let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else { - return Vec::new(); - }; - - let mut candidates = entries - .filter_map(Result::ok) - .map(|entry| entry.path()) - .filter(|path| path.is_dir()) - .map(|path| path.join("bin").join(binary_name)) - .collect::>(); - candidates.sort(); - candidates -} - -fn find_existing_binary(candidates: Vec) -> Option { - candidates.into_iter().find(|candidate| candidate.exists()) -} - -fn run_codex_agent_stream(request: AiAgentStreamRequest, emit: F) -> Result -where - F: FnMut(AiAgentStreamEvent), -{ - let binary = find_codex_binary()?; - run_codex_agent_stream_with_binary(&binary, request, emit) -} - -fn run_codex_agent_stream_with_binary( - binary: &Path, - request: AiAgentStreamRequest, - mut emit: F, -) -> Result -where - F: FnMut(AiAgentStreamEvent), -{ - let args = build_codex_args(&request)?; - let prompt = build_codex_prompt(&request); - - let mut command = build_codex_command(binary, args, prompt, &request.vault_path); - - let mut child = command - .spawn() - .map_err(|error| format!("Failed to spawn codex: {error}"))?; - - let stdout = child.stdout.take().ok_or("No stdout handle")?; - let reader = std::io::BufReader::new(stdout); - - let mut thread_id = String::new(); - - 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.trim().is_empty() { - continue; - } - - let json = match serde_json::from_str::(&line) { - Ok(json) => json, - Err(_) => continue, - }; - - if let Some(id) = json["thread_id"].as_str() { - thread_id = id.to_string(); - } - - dispatch_codex_event(&json, &mut emit); - } - - 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_codex_error(stderr_output, status.to_string()), - }); - } - - emit(AiAgentStreamEvent::Done); - - Ok(thread_id) -} - -fn build_codex_command( - binary: &Path, - args: Vec, - prompt: String, - vault_path: &str, -) -> std::process::Command { - let mut command = crate::hidden_command(binary); - command - .args(args) - .arg(prompt) - .current_dir(vault_path) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - command -} - -fn build_codex_args(request: &AiAgentStreamRequest) -> 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")? - .to_string(); - - Ok(vec![ - "--sandbox".into(), - "workspace-write".into(), - "--ask-for-approval".into(), - "never".into(), - "exec".into(), - "--json".into(), - "-C".into(), - request.vault_path.clone(), - "-c".into(), - r#"mcp_servers.tolaria.command="node""#.into(), - "-c".into(), - format!(r#"mcp_servers.tolaria.args=["{}"]"#, mcp_server_path), - "-c".into(), - format!( - r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#, - request.vault_path - ), - ]) -} - -fn build_codex_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 dispatch_codex_event(json: &serde_json::Value, emit: &mut F) -where - F: FnMut(AiAgentStreamEvent), -{ - match json["type"].as_str().unwrap_or_default() { - "thread.started" => { - if let Some(thread_id) = json["thread_id"].as_str() { - emit(AiAgentStreamEvent::Init { - session_id: thread_id.to_string(), - }); - } - } - "item.started" => emit_codex_item_event(json, false, emit), - "item.completed" => emit_codex_item_event(json, true, emit), - _ => {} - } -} - -fn emit_codex_item_event(json: &serde_json::Value, completed: bool, emit: &mut F) -where - F: FnMut(AiAgentStreamEvent), -{ - let item = &json["item"]; - let item_type = item["type"].as_str().unwrap_or_default(); - let item_id = item["id"].as_str().unwrap_or_default(); - - match item_type { - "command_execution" => { - if completed { - emit(AiAgentStreamEvent::ToolDone { - tool_id: item_id.to_string(), - output: item["aggregated_output"] - .as_str() - .map(|output| output.to_string()), - }); - } else { - emit(AiAgentStreamEvent::ToolStart { - tool_name: "Bash".into(), - tool_id: item_id.to_string(), - input: item["command"] - .as_str() - .map(|command| serde_json::json!({ "command": command }).to_string()), - }); - } - } - "agent_message" if completed => { - if let Some(text) = item["text"].as_str() { - emit(AiAgentStreamEvent::TextDelta { - text: text.to_string(), - }); - } - } - _ => {} - } -} - -fn format_codex_error(stderr_output: String, status: String) -> String { - let lower = stderr_output.to_ascii_lowercase(); - if is_codex_auth_error(&lower) { - return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into(); - } - - if is_codex_write_permission_error(&lower) { - return "Codex could not write to the active vault. Tolaria starts Codex with a workspace-write sandbox, so verify the selected vault folder is writable and retry; writes outside the active vault remain blocked.".into(); - } - - if stderr_output.trim().is_empty() { - format!("codex exited with status {status}") - } else { - stderr_output.lines().take(3).collect::>().join("\n") - } -} - -fn is_codex_auth_error(lower: &str) -> bool { - ["auth", "login", "sign in"] - .iter() - .any(|pattern| lower.contains(pattern)) -} - -fn is_codex_write_permission_error(lower: &str) -> bool { - [ - "read-only sandbox", - "writing is blocked", - "rejected by user approval", - "rejected by the environment", - ] - .iter() - .any(|pattern| lower.contains(pattern)) -} - fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option { match event { crate::claude_cli::ClaudeStreamEvent::Init { session_id } => { @@ -546,73 +178,6 @@ fn map_claude_event(event: crate::claude_cli::ClaudeStreamEvent) -> Option PathBuf { - use std::os::unix::fs::PermissionsExt; - - let script = dir.join(name); - std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap(); - std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); - script - } - - fn codex_request( - vault_path: &Path, - permission_mode: Option, - ) -> AiAgentStreamRequest { - AiAgentStreamRequest { - agent: AiAgentId::Codex, - message: "Summarize".into(), - system_prompt: None, - vault_path: vault_path.to_string_lossy().into_owned(), - permission_mode, - } - } - - fn assert_codex_workspace_write_contract(args: &[String]) { - let prefix = [ - "--sandbox", - "workspace-write", - "--ask-for-approval", - "never", - ]; - - assert_eq!(&args[..prefix.len()], prefix); - assert!(!args.iter().any(|arg| arg == "danger-full-access")); - assert!(!args - .iter() - .any(|arg| arg == "--dangerously-bypass-approvals-and-sandbox")); - } - - #[cfg(unix)] - fn run_codex_script(body: &str) -> (String, Vec) { - let dir = tempfile::tempdir().unwrap(); - let vault = tempfile::tempdir().unwrap(); - let binary = executable_script(dir.path(), "codex", body); - let mut events = Vec::new(); - let thread_id = run_codex_agent_stream_with_binary( - &binary, - codex_request(vault.path(), Some(AiAgentPermissionMode::Safe)), - |event| events.push(event), - ) - .unwrap(); - - (thread_id, events) - } - - fn assert_codex_text_flow(events: &[AiAgentStreamEvent], session: &str, text_delta: &str) { - assert!(matches!( - &events[0], - AiAgentStreamEvent::Init { session_id } if session_id == session - )); - assert!(matches!( - &events[1], - AiAgentStreamEvent::TextDelta { text } if text == text_delta - )); - assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); - } #[test] fn normalize_status_contains_all_agents() { @@ -629,242 +194,6 @@ mod tests { .all(|installed| matches!(installed, true | false))); } - #[test] - fn build_codex_prompt_keeps_system_prompt_first() { - let prompt = build_codex_prompt(&AiAgentStreamRequest { - agent: AiAgentId::Codex, - message: "Rename the note".into(), - system_prompt: Some("Be concise".into()), - vault_path: "/tmp/vault".into(), - permission_mode: Some(AiAgentPermissionMode::Safe), - }); - - assert!(prompt.starts_with("System instructions:\nBe concise")); - assert!(prompt.contains("User request:\nRename the note")); - } - - #[test] - fn build_codex_args_uses_safe_default_permissions() { - if let Ok(args) = build_codex_args(&AiAgentStreamRequest { - agent: AiAgentId::Codex, - message: "Rename the note".into(), - system_prompt: None, - vault_path: "/tmp/vault".into(), - permission_mode: None, - }) { - assert_eq!(args[4], "exec"); - assert_codex_workspace_write_contract(&args); - assert!(args.contains(&"--json".to_string())); - assert!(args.contains(&"-C".to_string())); - } - } - - #[test] - fn codex_permission_modes_keep_workspace_write_without_dangerous_bypass() { - for permission_mode in [ - AiAgentPermissionMode::Safe, - AiAgentPermissionMode::PowerUser, - ] { - if let Ok(args) = build_codex_args(&AiAgentStreamRequest { - agent: AiAgentId::Codex, - message: "Rename the note".into(), - system_prompt: None, - vault_path: "/tmp/vault".into(), - permission_mode: Some(permission_mode), - }) { - assert_codex_workspace_write_contract(&args); - } - } - } - - #[test] - fn build_codex_command_keeps_agent_process_contract() { - let binary = PathBuf::from("codex"); - let args = vec!["exec".to_string(), "--json".to_string()]; - let command = build_codex_command(&binary, args, "Summarize".into(), "/tmp/vault"); - let actual_args: Vec<&OsStr> = command.get_args().collect(); - - assert_eq!(command.get_program(), OsStr::new("codex")); - assert_eq!( - actual_args, - vec![ - OsStr::new("exec"), - OsStr::new("--json"), - OsStr::new("Summarize") - ] - ); - assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault"))); - } - - #[cfg(unix)] - #[test] - fn run_codex_agent_stream_reads_ndjson_and_returns_thread_id() { - let (thread_id, events) = run_codex_script( - r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}' -printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"Done"}}' -"#, - ); - - assert_eq!(thread_id, "thread_1"); - assert_codex_text_flow(&events, "thread_1", "Done"); - } - - #[cfg(unix)] - #[test] - fn run_codex_agent_stream_reports_nonzero_exit_errors() { - let (thread_id, events) = run_codex_script( - r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}' -printf '%s\n' 'login required' >&2 -exit 2 -"#, - ); - - assert_eq!(thread_id, "thread_1"); - assert!(events.iter().any(|event| matches!( - event, - AiAgentStreamEvent::Error { message } if message.contains("not authenticated") - ))); - assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); - } - - #[test] - fn codex_binary_candidates_include_supported_macos_installs() { - let home = PathBuf::from("/Users/alex"); - let candidates = codex_binary_candidates_for_home(&home); - let expected = [ - home.join(".local/bin/codex"), - home.join(".codex/bin/codex"), - home.join(".local/share/mise/shims/codex"), - home.join(".asdf/shims/codex"), - home.join(".npm-global/bin/codex"), - home.join(".bun/bin/codex"), - PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"), - ]; - - for candidate in expected { - assert!( - candidates.contains(&candidate), - "missing {}", - candidate.display() - ); - } - } - - #[test] - fn codex_binary_candidates_include_nvm_managed_node_installs() { - let home = tempfile::tempdir().unwrap(); - let codex = home.path().join(".nvm/versions/node/v22.12.0/bin/codex"); - std::fs::create_dir_all(codex.parent().unwrap()).unwrap(); - std::fs::write(&codex, "#!/bin/sh\n").unwrap(); - - let candidates = codex_binary_candidates_for_home(home.path()); - - assert!(candidates.contains(&codex), "missing {}", codex.display()); - } - - #[test] - fn first_existing_path_skips_empty_and_missing_lines() { - let dir = tempfile::tempdir().unwrap(); - let missing = dir.path().join("missing-codex"); - let codex = dir.path().join("codex"); - std::fs::write(&codex, "#!/bin/sh\n").unwrap(); - - let stdout = format!("\n{}\n{}\n", missing.display(), codex.display()); - - assert_eq!(first_existing_path(&stdout), Some(codex)); - } - - #[cfg(unix)] - #[test] - fn command_path_from_shell_finds_codex_from_login_shell() { - use std::os::unix::fs::PermissionsExt; - - let dir = tempfile::tempdir().unwrap(); - let codex = dir.path().join("codex"); - std::fs::write(&codex, "#!/bin/sh\n").unwrap(); - std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap(); - - let shell = dir.path().join("shell"); - std::fs::write( - &shell, - format!( - "#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n", - codex.display() - ), - ) - .unwrap(); - std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap(); - - assert_eq!(command_path_from_shell(&shell, "codex"), Some(codex)); - } - - #[test] - fn dispatch_codex_command_events_maps_to_bash_events() { - let mut events = Vec::new(); - let started = serde_json::json!({ - "type": "item.started", - "item": { - "id": "item_1", - "type": "command_execution", - "command": "/bin/zsh -lc pwd" - } - }); - let completed = serde_json::json!({ - "type": "item.completed", - "item": { - "id": "item_1", - "type": "command_execution", - "aggregated_output": "/private/tmp\n" - } - }); - - dispatch_codex_event(&started, &mut |event| events.push(event)); - dispatch_codex_event(&completed, &mut |event| events.push(event)); - - assert!(matches!( - &events[0], - AiAgentStreamEvent::ToolStart { tool_name, tool_id, .. } - if tool_name == "Bash" && tool_id == "item_1" - )); - assert!(matches!( - &events[1], - AiAgentStreamEvent::ToolDone { tool_id, output } - if tool_id == "item_1" && output.as_deref() == Some("/private/tmp\n") - )); - } - - #[test] - fn dispatch_codex_agent_message_maps_to_text_delta() { - let mut events = Vec::new(); - let completed = serde_json::json!({ - "type": "item.completed", - "item": { - "id": "item_2", - "type": "agent_message", - "text": "All set" - } - }); - - dispatch_codex_event(&completed, &mut |event| events.push(event)); - - assert!(matches!( - &events[0], - AiAgentStreamEvent::TextDelta { text } if text == "All set" - )); - } - - #[test] - fn format_codex_error_explains_vault_write_permission_failures() { - let message = format_codex_error( - "The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(), - "exit status: 1".into(), - ); - - assert!(message.contains("active vault")); - assert!(message.contains("writable")); - assert!(message.contains("outside")); - } - #[test] fn map_claude_done_event_preserves_completion_signal() { let mapped = map_claude_event(crate::claude_cli::ClaudeStreamEvent::Done); diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs index 518b52d5..3b7c082b 100644 --- a/src-tauri/src/claude_cli.rs +++ b/src-tauri/src/claude_cli.rs @@ -1,7 +1,7 @@ use crate::ai_agents::AiAgentPermissionMode; +pub use crate::cli_agent_runtime::AgentStreamRequest; use serde::{Deserialize, Serialize}; use std::collections::HashMap; -use std::io::BufRead; use std::path::{Path, PathBuf}; use std::process::{ExitStatus, Stdio}; @@ -51,15 +51,6 @@ pub struct ChatStreamRequest { pub session_id: Option, } -/// Parameters accepted by Claude Code agent streams. -#[derive(Debug, Deserialize)] -pub struct AgentStreamRequest { - pub message: String, - pub system_prompt: Option, - pub vault_path: String, - pub permission_mode: AiAgentPermissionMode, -} - // --------------------------------------------------------------------------- // Finding the `claude` binary // --------------------------------------------------------------------------- @@ -212,16 +203,9 @@ pub fn check_cli() -> ClaudeCliStatus { } }; - let version = crate::hidden_command(&bin) - .arg("--version") - .output() - .ok() - .filter(|o| o.status.success()) - .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string()); - ClaudeCliStatus { installed: true, - version, + version: crate::cli_agent_runtime::version_for_binary(&bin), } } @@ -319,13 +303,12 @@ fn agent_tools(permission_mode: AiAgentPermissionMode) -> &'static str { /// Build a temporary MCP config JSON string pointing to the vault MCP server. fn build_mcp_config(vault_path: &str) -> Result { - let server_dir = crate::mcp::mcp_server_dir()?; - let index_js = server_dir.join("index.js"); + let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?; let config = serde_json::json!({ "mcpServers": { "tolaria": { "command": "node", - "args": [index_js.to_string_lossy()], + "args": [mcp_server_path], "env": { "VAULT_PATH": vault_path } } } @@ -355,14 +338,6 @@ fn run_claude_subprocess( where F: FnMut(ClaudeStreamEvent), { - let mut cmd = build_claude_command(bin, args, cwd); - let mut child = cmd - .spawn() - .map_err(|e| format!("Failed to spawn claude: {e}"))?; - - let stdout = child.stdout.take().ok_or("No stdout handle")?; - let reader = std::io::BufReader::new(stdout); - let mut state = StreamState { session_id: String::new(), tool_inputs: HashMap::new(), @@ -370,41 +345,21 @@ where emitted_text: false, }; - for line in reader.lines() { - let line = match line { - Ok(l) => l, - Err(e) => { - emit(ClaudeStreamEvent::Error { - message: format!("Read error: {e}"), - }); - break; - } - }; + let cmd = build_claude_command(bin, args, cwd); + let run = crate::cli_agent_runtime::run_json_line_process( + cmd, + "claude", + emit, + |message| ClaudeStreamEvent::Error { message }, + |json, emit, session_id| { + dispatch_event(json, &mut state, emit); + *session_id = state.session_id.clone(); + }, + )?; - if line.trim().is_empty() { - continue; - } - - let json: serde_json::Value = match serde_json::from_str(&line) { - Ok(v) => v, - Err(_) => continue, // skip non-JSON lines - }; - - dispatch_event(&json, &mut state, emit); - } - - // Read stderr for potential error messages. - let stderr_output = child - .stderr - .take() - .and_then(|s| std::io::read_to_string(s).ok()) - .unwrap_or_default(); - - let status = child.wait().map_err(|e| format!("Wait failed: {e}"))?; - - if !status.success() && state.session_id.is_empty() { + if !run.status.success() && state.session_id.is_empty() { emit(ClaudeStreamEvent::Error { - message: format_failed_claude_exit(&stderr_output, status), + message: format_failed_claude_exit(&run.stderr_output, run.status), }); } diff --git a/src-tauri/src/cli_agent_runtime.rs b/src-tauri/src/cli_agent_runtime.rs new file mode 100644 index 00000000..81bb0fdf --- /dev/null +++ b/src-tauri/src/cli_agent_runtime.rs @@ -0,0 +1,186 @@ +use crate::ai_agents::{AiAgentPermissionMode, AiAgentStreamEvent}; +use serde::Deserialize; +use std::io::BufRead; +use std::path::PathBuf; +use std::process::{Command, ExitStatus}; + +#[derive(Debug, Clone, Deserialize)] +pub struct AgentStreamRequest { + pub message: String, + pub system_prompt: Option, + pub vault_path: String, + pub permission_mode: AiAgentPermissionMode, +} + +pub(crate) struct JsonLineRun { + pub session_id: String, + pub stderr_output: String, + pub status: ExitStatus, +} + +pub(crate) fn build_prompt(message: &str, system_prompt: Option<&str>) -> String { + match system_prompt + .map(str::trim) + .filter(|prompt| !prompt.is_empty()) + { + Some(system_prompt) => { + format!("System instructions:\n{system_prompt}\n\nUser request:\n{message}") + } + None => message.to_string(), + } +} + +pub(crate) fn mcp_server_path_string() -> Result { + Ok(crate::mcp::mcp_server_dir()? + .join("index.js") + .to_str() + .ok_or("Invalid MCP server path")? + .to_string()) +} + +pub(crate) fn version_for_binary(binary: &PathBuf) -> Option { + crate::hidden_command(binary) + .arg("--version") + .output() + .ok() + .filter(|output| output.status.success()) + .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +pub(crate) fn parse_json_line( + line: Result, +) -> Result, String> { + let line = line.map_err(|error| format!("Read error: {error}"))?; + let trimmed = line.trim(); + if trimmed.is_empty() { + return Ok(None); + } + + Ok(serde_json::from_str::(trimmed).ok()) +} + +#[cfg(test)] +pub(crate) fn parse_ai_agent_json_line( + line: Result, + emit: &mut F, +) -> Option +where + F: FnMut(AiAgentStreamEvent), +{ + match parse_json_line(line) { + Ok(json) => json, + Err(message) => { + emit(AiAgentStreamEvent::Error { message }); + None + } + } +} + +pub(crate) fn run_json_line_process( + mut command: Command, + process_name: &'static str, + emit: &mut F, + error_event: impl Fn(String) -> Event, + mut handle_json: H, +) -> Result +where + F: FnMut(Event), + H: FnMut(&serde_json::Value, &mut F, &mut String), +{ + let mut child = command + .spawn() + .map_err(|error| format!("Failed to spawn {process_name}: {error}"))?; + let stdout = child.stdout.take().ok_or("No stdout handle")?; + let reader = std::io::BufReader::new(stdout); + let mut session_id = String::new(); + + for line in reader.lines() { + match parse_json_line(line) { + Ok(Some(json)) => handle_json(&json, emit, &mut session_id), + Ok(None) => {} + Err(message) => { + emit(error_event(message)); + break; + } + } + } + + 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}"))?; + + Ok(JsonLineRun { + session_id, + stderr_output, + status, + }) +} + +pub(crate) fn run_ai_agent_json_stream( + command: Command, + process_name: &'static str, + mut emit: F, + session_id: impl Fn(&serde_json::Value) -> Option<&str>, + dispatch_event: impl Fn(&serde_json::Value, &mut F), + format_error: impl Fn(String, String) -> String, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let run = run_json_line_process( + command, + process_name, + &mut emit, + |message| AiAgentStreamEvent::Error { message }, + |json, emit, active_session_id| { + if let Some(id) = session_id(json) { + *active_session_id = id.to_string(); + } + dispatch_event(json, emit); + }, + )?; + + if !run.status.success() { + emit(AiAgentStreamEvent::Error { + message: format_error(run.stderr_output, run.status.to_string()), + }); + } + + emit(AiAgentStreamEvent::Done); + Ok(run.session_id) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_prompt_keeps_system_prompt_first() { + let prompt = build_prompt("Rename the note", Some("Be concise")); + + assert!(prompt.starts_with("System instructions:\nBe concise")); + assert!(prompt.contains("User request:\nRename the note")); + } + + #[test] + fn build_prompt_skips_blank_system_prompt() { + assert_eq!( + build_prompt("Rename the note", Some(" ")), + "Rename the note" + ); + } + + #[test] + fn parse_json_line_reports_read_errors_and_skips_blank_or_invalid_lines() { + assert!(parse_json_line(Ok(" ".into())).unwrap().is_none()); + assert!(parse_json_line(Ok("not json".into())).unwrap().is_none()); + + let error = parse_json_line(Err(std::io::Error::other("broken pipe"))).unwrap_err(); + assert!(error.contains("broken pipe")); + } +} diff --git a/src-tauri/src/codex_cli.rs b/src-tauri/src/codex_cli.rs new file mode 100644 index 00000000..99f6699a --- /dev/null +++ b/src-tauri/src/codex_cli.rs @@ -0,0 +1,607 @@ +use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent}; +pub use crate::cli_agent_runtime::AgentStreamRequest; +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +pub fn check_cli() -> AiAgentAvailability { + let binary = match find_codex_binary() { + Ok(binary) => binary, + Err(_) => { + return AiAgentAvailability { + installed: false, + version: None, + } + } + }; + + AiAgentAvailability { + installed: true, + version: crate::cli_agent_runtime::version_for_binary(&binary), + } +} + +pub fn run_agent_stream(request: AgentStreamRequest, emit: F) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let binary = find_codex_binary()?; + run_agent_stream_with_binary(&binary, request, emit) +} + +fn find_codex_binary() -> Result { + if let Some(binary) = find_codex_binary_on_path() { + return Ok(binary); + } + + if let Some(binary) = find_codex_binary_in_user_shell() { + return Ok(binary); + } + + if let Some(binary) = find_existing_binary(codex_binary_candidates()) { + return Ok(binary); + } + + Err("Codex CLI not found. Install it: https://developers.openai.com/codex/cli".into()) +} + +fn find_codex_binary_on_path() -> Option { + crate::hidden_command("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") { + if !shell.is_empty() { + shells.push(PathBuf::from(shell)); + } + } + shells.push(PathBuf::from("/bin/zsh")); + shells.push(PathBuf::from("/bin/bash")); + shells +} + +fn command_path_from_shell(shell: &Path, command: &str) -> Option { + crate::hidden_command(shell) + .arg("-lc") + .arg(format!("command -v {command}")) + .output() + .ok() + .and_then(|output| path_from_successful_output(&output)) +} + +fn path_from_successful_output(output: &std::process::Output) -> Option { + if output.status.success() { + first_existing_path(&String::from_utf8_lossy(&output.stdout)) + } else { + None + } +} + +fn first_existing_path(stdout: &str) -> Option { + stdout.lines().find_map(|line| { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let candidate = PathBuf::from(trimmed); + candidate.exists().then_some(candidate) + }) +} + +fn codex_binary_candidates() -> Vec { + dirs::home_dir() + .map(|home| codex_binary_candidates_for_home(&home)) + .unwrap_or_default() +} + +fn codex_binary_candidates_for_home(home: &Path) -> Vec { + let mut candidates = vec![ + home.join(".local/bin/codex"), + home.join(".codex/bin/codex"), + home.join(".local/share/mise/shims/codex"), + home.join(".asdf/shims/codex"), + home.join(".npm-global/bin/codex"), + home.join(".npm/bin/codex"), + home.join(".bun/bin/codex"), + PathBuf::from("/usr/local/bin/codex"), + PathBuf::from("/opt/homebrew/bin/codex"), + PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"), + ]; + candidates.extend(nvm_node_binary_candidates_for_home(home, "codex")); + candidates +} + +fn nvm_node_binary_candidates_for_home(home: &Path, binary_name: &str) -> Vec { + let Ok(entries) = std::fs::read_dir(home.join(".nvm/versions/node")) else { + return Vec::new(); + }; + + let mut candidates = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .map(|path| path.join("bin").join(binary_name)) + .collect::>(); + candidates.sort(); + candidates +} + +fn find_existing_binary(candidates: Vec) -> Option { + candidates.into_iter().find(|candidate| candidate.exists()) +} + +fn run_agent_stream_with_binary( + binary: &Path, + request: AgentStreamRequest, + emit: F, +) -> Result +where + F: FnMut(AiAgentStreamEvent), +{ + let args = build_codex_args(&request)?; + let prompt = build_codex_prompt(&request); + let command = build_codex_command(binary, args, prompt, &request.vault_path); + + crate::cli_agent_runtime::run_ai_agent_json_stream( + command, + "codex", + emit, + codex_session_id, + dispatch_codex_event, + format_codex_error, + ) +} + +fn build_codex_command( + binary: &Path, + args: Vec, + prompt: String, + vault_path: &str, +) -> std::process::Command { + let mut command = crate::hidden_command(binary); + command + .args(args) + .arg(prompt) + .current_dir(vault_path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + command +} + +fn build_codex_args(request: &AgentStreamRequest) -> Result, String> { + let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?; + + Ok(vec![ + "--sandbox".into(), + "workspace-write".into(), + "--ask-for-approval".into(), + "never".into(), + "exec".into(), + "--json".into(), + "-C".into(), + request.vault_path.clone(), + "-c".into(), + r#"mcp_servers.tolaria.command="node""#.into(), + "-c".into(), + format!(r#"mcp_servers.tolaria.args=["{}"]"#, mcp_server_path), + "-c".into(), + format!( + r#"mcp_servers.tolaria.env={{VAULT_PATH="{}"}}"#, + request.vault_path + ), + ]) +} + +fn build_codex_prompt(request: &AgentStreamRequest) -> String { + crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref()) +} + +fn codex_session_id(json: &serde_json::Value) -> Option<&str> { + json["thread_id"].as_str() +} + +fn dispatch_codex_event(json: &serde_json::Value, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + match json["type"].as_str().unwrap_or_default() { + "thread.started" => { + if let Some(thread_id) = json["thread_id"].as_str() { + emit(AiAgentStreamEvent::Init { + session_id: thread_id.to_string(), + }); + } + } + "item.started" => emit_codex_item_event(json, false, emit), + "item.completed" => emit_codex_item_event(json, true, emit), + _ => {} + } +} + +fn emit_codex_item_event(json: &serde_json::Value, completed: bool, emit: &mut F) +where + F: FnMut(AiAgentStreamEvent), +{ + let item = &json["item"]; + let item_type = item["type"].as_str().unwrap_or_default(); + let item_id = item["id"].as_str().unwrap_or_default(); + + match item_type { + "command_execution" => { + if completed { + emit(AiAgentStreamEvent::ToolDone { + tool_id: item_id.to_string(), + output: item["aggregated_output"] + .as_str() + .map(|output| output.to_string()), + }); + } else { + emit(AiAgentStreamEvent::ToolStart { + tool_name: "Bash".into(), + tool_id: item_id.to_string(), + input: item["command"] + .as_str() + .map(|command| serde_json::json!({ "command": command }).to_string()), + }); + } + } + "agent_message" if completed => { + if let Some(text) = item["text"].as_str() { + emit(AiAgentStreamEvent::TextDelta { + text: text.to_string(), + }); + } + } + _ => {} + } +} + +fn format_codex_error(stderr_output: String, status: String) -> String { + let lower = stderr_output.to_ascii_lowercase(); + if is_codex_auth_error(&lower) { + return "Codex CLI is not authenticated. Run `codex login` or launch `codex` in your terminal.".into(); + } + + if is_codex_write_permission_error(&lower) { + return "Codex could not write to the active vault. Tolaria starts Codex with a workspace-write sandbox, so verify the selected vault folder is writable and retry; writes outside the active vault remain blocked.".into(); + } + + if stderr_output.trim().is_empty() { + format!("codex exited with status {status}") + } else { + stderr_output.lines().take(3).collect::>().join("\n") + } +} + +fn is_codex_auth_error(lower: &str) -> bool { + ["auth", "login", "sign in"] + .iter() + .any(|pattern| lower.contains(pattern)) +} + +fn is_codex_write_permission_error(lower: &str) -> bool { + [ + "read-only sandbox", + "writing is blocked", + "rejected by user approval", + "rejected by the environment", + ] + .iter() + .any(|pattern| lower.contains(pattern)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ai_agents::AiAgentPermissionMode; + use std::ffi::OsStr; + + #[cfg(unix)] + fn executable_script(dir: &Path, name: &str, body: &str) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + + let script = dir.join(name); + std::fs::write(&script, format!("#!/bin/sh\n{body}")).unwrap(); + std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap(); + script + } + + fn codex_request( + vault_path: &Path, + permission_mode: AiAgentPermissionMode, + ) -> AgentStreamRequest { + AgentStreamRequest { + message: "Summarize".into(), + system_prompt: None, + vault_path: vault_path.to_string_lossy().into_owned(), + permission_mode, + } + } + + fn assert_codex_workspace_write_contract(args: &[String]) { + let prefix = [ + "--sandbox", + "workspace-write", + "--ask-for-approval", + "never", + ]; + + assert_eq!(&args[..prefix.len()], prefix); + assert!(!args.iter().any(|arg| arg == "danger-full-access")); + assert!(!args + .iter() + .any(|arg| arg == "--dangerously-bypass-approvals-and-sandbox")); + } + + #[cfg(unix)] + fn run_codex_script(body: &str) -> (String, Vec) { + let dir = tempfile::tempdir().unwrap(); + let vault = tempfile::tempdir().unwrap(); + let binary = executable_script(dir.path(), "codex", body); + let mut events = Vec::new(); + let thread_id = run_agent_stream_with_binary( + &binary, + codex_request(vault.path(), AiAgentPermissionMode::Safe), + |event| events.push(event), + ) + .unwrap(); + + (thread_id, events) + } + + fn assert_codex_text_flow(events: &[AiAgentStreamEvent], session: &str, text_delta: &str) { + assert!(matches!( + &events[0], + AiAgentStreamEvent::Init { session_id } if session_id == session + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::TextDelta { text } if text == text_delta + )); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } + + #[test] + fn build_codex_prompt_keeps_system_prompt_first() { + let prompt = build_codex_prompt(&AgentStreamRequest { + message: "Rename the note".into(), + system_prompt: Some("Be concise".into()), + vault_path: "/tmp/vault".into(), + permission_mode: AiAgentPermissionMode::Safe, + }); + + assert!(prompt.starts_with("System instructions:\nBe concise")); + assert!(prompt.contains("User request:\nRename the note")); + } + + #[test] + fn build_codex_args_uses_safe_default_permissions() { + if let Ok(args) = build_codex_args(&AgentStreamRequest { + message: "Rename the note".into(), + system_prompt: None, + vault_path: "/tmp/vault".into(), + permission_mode: AiAgentPermissionMode::Safe, + }) { + assert_eq!(args[4], "exec"); + assert_codex_workspace_write_contract(&args); + assert!(args.contains(&"--json".to_string())); + assert!(args.contains(&"-C".to_string())); + } + } + + #[test] + fn codex_permission_modes_keep_workspace_write_without_dangerous_bypass() { + for permission_mode in [ + AiAgentPermissionMode::Safe, + AiAgentPermissionMode::PowerUser, + ] { + if let Ok(args) = build_codex_args(&AgentStreamRequest { + message: "Rename the note".into(), + system_prompt: None, + vault_path: "/tmp/vault".into(), + permission_mode, + }) { + assert_codex_workspace_write_contract(&args); + } + } + } + + #[test] + fn build_codex_command_keeps_agent_process_contract() { + let binary = PathBuf::from("codex"); + let args = vec!["exec".to_string(), "--json".to_string()]; + let command = build_codex_command(&binary, args, "Summarize".into(), "/tmp/vault"); + let actual_args: Vec<&OsStr> = command.get_args().collect(); + + assert_eq!(command.get_program(), OsStr::new("codex")); + assert_eq!( + actual_args, + vec![ + OsStr::new("exec"), + OsStr::new("--json"), + OsStr::new("Summarize") + ] + ); + assert_eq!(command.get_current_dir(), Some(Path::new("/tmp/vault"))); + } + + #[cfg(unix)] + #[test] + fn run_codex_agent_stream_reads_ndjson_and_returns_thread_id() { + let (thread_id, events) = run_codex_script( + r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}' +printf '%s\n' '{"type":"item.completed","item":{"id":"msg_1","type":"agent_message","text":"Done"}}' +"#, + ); + + assert_eq!(thread_id, "thread_1"); + assert_codex_text_flow(&events, "thread_1", "Done"); + } + + #[cfg(unix)] + #[test] + fn run_codex_agent_stream_reports_nonzero_exit_errors() { + let (thread_id, events) = run_codex_script( + r#"printf '%s\n' '{"type":"thread.started","thread_id":"thread_1"}' +printf '%s\n' 'login required' >&2 +exit 2 +"#, + ); + + assert_eq!(thread_id, "thread_1"); + assert!(events.iter().any(|event| matches!( + event, + AiAgentStreamEvent::Error { message } if message.contains("not authenticated") + ))); + assert!(matches!(events.last(), Some(AiAgentStreamEvent::Done))); + } + + #[test] + fn codex_binary_candidates_include_supported_macos_installs() { + let home = PathBuf::from("/Users/alex"); + let candidates = codex_binary_candidates_for_home(&home); + let expected = [ + home.join(".local/bin/codex"), + home.join(".codex/bin/codex"), + home.join(".local/share/mise/shims/codex"), + home.join(".asdf/shims/codex"), + home.join(".npm-global/bin/codex"), + home.join(".bun/bin/codex"), + PathBuf::from("/Applications/Codex.app/Contents/Resources/codex"), + ]; + + for candidate in expected { + assert!( + candidates.contains(&candidate), + "missing {}", + candidate.display() + ); + } + } + + #[test] + fn codex_binary_candidates_include_nvm_managed_node_installs() { + let home = tempfile::tempdir().unwrap(); + let codex = home.path().join(".nvm/versions/node/v22.12.0/bin/codex"); + std::fs::create_dir_all(codex.parent().unwrap()).unwrap(); + std::fs::write(&codex, "#!/bin/sh\n").unwrap(); + + let candidates = codex_binary_candidates_for_home(home.path()); + + assert!(candidates.contains(&codex), "missing {}", codex.display()); + } + + #[test] + fn first_existing_path_skips_empty_and_missing_lines() { + let dir = tempfile::tempdir().unwrap(); + let missing = dir.path().join("missing-codex"); + let codex = dir.path().join("codex"); + std::fs::write(&codex, "#!/bin/sh\n").unwrap(); + + let stdout = format!("\n{}\n{}\n", missing.display(), codex.display()); + + assert_eq!(first_existing_path(&stdout), Some(codex)); + } + + #[cfg(unix)] + #[test] + fn command_path_from_shell_finds_codex_from_login_shell() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempfile::tempdir().unwrap(); + let codex = dir.path().join("codex"); + std::fs::write(&codex, "#!/bin/sh\n").unwrap(); + std::fs::set_permissions(&codex, std::fs::Permissions::from_mode(0o755)).unwrap(); + + let shell = dir.path().join("shell"); + std::fs::write( + &shell, + format!( + "#!/bin/sh\nif [ \"$1\" = \"-lc\" ]; then echo '{}'; fi\n", + codex.display() + ), + ) + .unwrap(); + std::fs::set_permissions(&shell, std::fs::Permissions::from_mode(0o755)).unwrap(); + + assert_eq!(command_path_from_shell(&shell, "codex"), Some(codex)); + } + + #[test] + fn dispatch_codex_command_events_maps_to_bash_events() { + let mut events = Vec::new(); + let started = serde_json::json!({ + "type": "item.started", + "item": { + "id": "item_1", + "type": "command_execution", + "command": "/bin/zsh -lc pwd" + } + }); + let completed = serde_json::json!({ + "type": "item.completed", + "item": { + "id": "item_1", + "type": "command_execution", + "aggregated_output": "/private/tmp\n" + } + }); + + dispatch_codex_event(&started, &mut |event| events.push(event)); + dispatch_codex_event(&completed, &mut |event| events.push(event)); + + assert!(matches!( + &events[0], + AiAgentStreamEvent::ToolStart { tool_name, tool_id, .. } + if tool_name == "Bash" && tool_id == "item_1" + )); + assert!(matches!( + &events[1], + AiAgentStreamEvent::ToolDone { tool_id, output } + if tool_id == "item_1" && output.as_deref() == Some("/private/tmp\n") + )); + } + + #[test] + fn dispatch_codex_agent_message_maps_to_text_delta() { + let mut events = Vec::new(); + let completed = serde_json::json!({ + "type": "item.completed", + "item": { + "id": "item_2", + "type": "agent_message", + "text": "All set" + } + }); + + dispatch_codex_event(&completed, &mut |event| events.push(event)); + + assert!(matches!( + &events[0], + AiAgentStreamEvent::TextDelta { text } if text == "All set" + )); + } + + #[test] + fn format_codex_error_explains_vault_write_permission_failures() { + let message = format_codex_error( + "The patch was rejected by the environment: writing is blocked by read-only sandbox; rejected by user approval settings".into(), + "exit status: 1".into(), + ); + + assert!(message.contains("active vault")); + assert!(message.contains("writable")); + assert!(message.contains("outside")); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9d3167b5..2b7bcbfe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,6 +1,8 @@ pub mod ai_agents; pub mod app_updater; pub mod claude_cli; +mod cli_agent_runtime; +pub mod codex_cli; mod commands; pub mod frontmatter; pub mod git; diff --git a/src-tauri/src/opencode_cli.rs b/src-tauri/src/opencode_cli.rs index b22b43fd..bf538d29 100644 --- a/src-tauri/src/opencode_cli.rs +++ b/src-tauri/src/opencode_cli.rs @@ -1,15 +1,7 @@ -use crate::ai_agents::{AiAgentAvailability, AiAgentPermissionMode, AiAgentStreamEvent}; -use std::io::BufRead; +use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent}; +pub use crate::cli_agent_runtime::AgentStreamRequest; use std::path::Path; -#[derive(Debug, Clone)] -pub struct AgentStreamRequest { - pub message: String, - pub system_prompt: Option, - pub vault_path: String, - pub permission_mode: AiAgentPermissionMode, -} - pub fn check_cli() -> AiAgentAvailability { crate::opencode_discovery::check_cli() } @@ -25,54 +17,26 @@ where fn run_agent_stream_with_binary( binary: &Path, request: AgentStreamRequest, - mut emit: F, + emit: F, ) -> Result where F: FnMut(AiAgentStreamEvent), { - let mut command = crate::opencode_config::build_command(binary, &request)?; - let mut child = command - .spawn() - .map_err(|error| format!("Failed to spawn opencode: {error}"))?; - - let stdout = child.stdout.take().ok_or("No stdout handle")?; - let reader = std::io::BufReader::new(stdout); - let mut session_id = String::new(); - - for line in reader.lines() { - let json = match crate::opencode_events::parse_line(line, &mut emit) { - Some(json) => json, - None => continue, - }; - - if let Some(id) = crate::opencode_events::session_id(&json) { - session_id = id.to_string(); - } - - crate::opencode_events::dispatch_event(&json, &mut emit); - } - - 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: crate::opencode_events::format_error(stderr_output, status.to_string()), - }); - } - - emit(AiAgentStreamEvent::Done); - Ok(session_id) + let command = crate::opencode_config::build_command(binary, &request)?; + crate::cli_agent_runtime::run_ai_agent_json_stream( + command, + "opencode", + emit, + crate::opencode_events::session_id, + crate::opencode_events::dispatch_event, + crate::opencode_events::format_error, + ) } #[cfg(test)] mod tests { use super::*; + use crate::ai_agents::AiAgentPermissionMode; #[cfg(unix)] fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf { diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs index af220d47..3051ae4f 100644 --- a/src-tauri/src/opencode_config.rs +++ b/src-tauri/src/opencode_config.rs @@ -27,29 +27,14 @@ fn build_args() -> Vec { } fn build_prompt(request: &AgentStreamRequest) -> 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(), - } + crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref()) } fn build_config( vault_path: &str, permission_mode: AiAgentPermissionMode, ) -> Result { - 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")? - .to_string(); + let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?; serde_json::to_string(&serde_json::json!({ "$schema": "https://opencode.ai/config.json", diff --git a/src-tauri/src/opencode_discovery.rs b/src-tauri/src/opencode_discovery.rs index fb514cf8..c6f68073 100644 --- a/src-tauri/src/opencode_discovery.rs +++ b/src-tauri/src/opencode_discovery.rs @@ -5,7 +5,7 @@ pub(crate) fn check_cli() -> AiAgentAvailability { match find_binary() { Ok(binary) => AiAgentAvailability { installed: true, - version: version_for_binary(&binary), + version: crate::cli_agent_runtime::version_for_binary(&binary), }, Err(_) => AiAgentAvailability { installed: false, @@ -30,15 +30,6 @@ pub(crate) fn find_binary() -> Result { Err("OpenCode CLI not found. Install it: https://opencode.ai/docs/".into()) } -fn version_for_binary(binary: &PathBuf) -> Option { - crate::hidden_command(binary) - .arg("--version") - .output() - .ok() - .filter(|output| output.status.success()) - .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) -} - fn find_binary_on_path() -> Option { crate::hidden_command(path_lookup_command()) .arg("opencode") diff --git a/src-tauri/src/opencode_events.rs b/src-tauri/src/opencode_events.rs index 200ba71d..7cdc04b7 100644 --- a/src-tauri/src/opencode_events.rs +++ b/src-tauri/src/opencode_events.rs @@ -1,5 +1,6 @@ use crate::ai_agents::AiAgentStreamEvent; +#[cfg(test)] pub(crate) fn parse_line( line: Result, emit: &mut F, @@ -7,22 +8,7 @@ pub(crate) fn parse_line( where F: FnMut(AiAgentStreamEvent), { - let line = match line { - Ok(line) => line, - Err(error) => { - emit(AiAgentStreamEvent::Error { - message: format!("Read error: {error}"), - }); - return None; - } - }; - - let trimmed = line.trim(); - if trimmed.is_empty() { - return None; - } - - serde_json::from_str::(trimmed).ok() + crate::cli_agent_runtime::parse_ai_agent_json_line(line, emit) } pub(crate) fn dispatch_event(json: &serde_json::Value, emit: &mut F) diff --git a/src-tauri/src/pi_cli.rs b/src-tauri/src/pi_cli.rs index 2b83a86c..1d801c31 100644 --- a/src-tauri/src/pi_cli.rs +++ b/src-tauri/src/pi_cli.rs @@ -1,15 +1,7 @@ -use crate::ai_agents::{AiAgentAvailability, AiAgentPermissionMode, AiAgentStreamEvent}; -use std::io::BufRead; +use crate::ai_agents::{AiAgentAvailability, AiAgentStreamEvent}; +pub use crate::cli_agent_runtime::AgentStreamRequest; use std::path::Path; -#[derive(Debug, Clone)] -pub struct AgentStreamRequest { - pub message: String, - pub system_prompt: Option, - pub vault_path: String, - pub permission_mode: AiAgentPermissionMode, -} - pub fn check_cli() -> AiAgentAvailability { crate::pi_discovery::check_cli() } @@ -25,7 +17,7 @@ where fn run_agent_stream_with_binary( binary: &Path, request: AgentStreamRequest, - mut emit: F, + emit: F, ) -> Result where F: FnMut(AiAgentStreamEvent), @@ -34,49 +26,21 @@ where .prefix("tolaria-pi-agent-") .tempdir() .map_err(|error| format!("Failed to create Pi config directory: {error}"))?; - let mut command = crate::pi_config::build_command(binary, &request, agent_dir.path())?; - let mut child = command - .spawn() - .map_err(|error| format!("Failed to spawn pi: {error}"))?; - - let stdout = child.stdout.take().ok_or("No stdout handle")?; - let reader = std::io::BufReader::new(stdout); - let mut session_id = String::new(); - - for line in reader.lines() { - let json = match crate::pi_events::parse_line(line, &mut emit) { - Some(json) => json, - None => continue, - }; - - if let Some(id) = crate::pi_events::session_id(&json) { - session_id = id.to_string(); - } - - crate::pi_events::dispatch_event(&json, &mut emit); - } - - 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: crate::pi_events::format_error(stderr_output, status.to_string()), - }); - } - - emit(AiAgentStreamEvent::Done); - Ok(session_id) + let command = crate::pi_config::build_command(binary, &request, agent_dir.path())?; + crate::cli_agent_runtime::run_ai_agent_json_stream( + command, + "pi", + emit, + crate::pi_events::session_id, + crate::pi_events::dispatch_event, + crate::pi_events::format_error, + ) } #[cfg(test)] mod tests { use super::*; + use crate::ai_agents::AiAgentPermissionMode; #[cfg(unix)] fn executable_script(dir: &Path, body: &str) -> std::path::PathBuf { diff --git a/src-tauri/src/pi_config.rs b/src-tauri/src/pi_config.rs index 9da57ac8..97bef3a7 100644 --- a/src-tauri/src/pi_config.rs +++ b/src-tauri/src/pi_config.rs @@ -33,18 +33,7 @@ fn build_args() -> Vec { } fn build_prompt(request: &AgentStreamRequest) -> 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(), - } + crate::cli_agent_runtime::build_prompt(&request.message, request.system_prompt.as_deref()) } fn write_mcp_config( @@ -63,11 +52,7 @@ fn build_mcp_config( vault_path: &str, _permission_mode: AiAgentPermissionMode, ) -> Result { - 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")? - .to_string(); + let mcp_server_path = crate::cli_agent_runtime::mcp_server_path_string()?; serde_json::to_string(&serde_json::json!({ "settings": { diff --git a/src-tauri/src/pi_discovery.rs b/src-tauri/src/pi_discovery.rs index 08fb6d15..4a33db7a 100644 --- a/src-tauri/src/pi_discovery.rs +++ b/src-tauri/src/pi_discovery.rs @@ -5,7 +5,7 @@ pub(crate) fn check_cli() -> AiAgentAvailability { match find_binary() { Ok(binary) => AiAgentAvailability { installed: true, - version: version_for_binary(&binary), + version: crate::cli_agent_runtime::version_for_binary(&binary), }, Err(_) => AiAgentAvailability { installed: false, @@ -30,15 +30,6 @@ pub(crate) fn find_binary() -> Result { Err("Pi CLI not found. Install it: https://pi.dev".into()) } -fn version_for_binary(binary: &PathBuf) -> Option { - crate::hidden_command(binary) - .arg("--version") - .output() - .ok() - .filter(|output| output.status.success()) - .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string()) -} - fn find_binary_on_path() -> Option { crate::hidden_command(path_lookup_command()) .arg("pi") diff --git a/src-tauri/src/pi_events.rs b/src-tauri/src/pi_events.rs index 29042273..a57f9cd6 100644 --- a/src-tauri/src/pi_events.rs +++ b/src-tauri/src/pi_events.rs @@ -1,5 +1,6 @@ use crate::ai_agents::AiAgentStreamEvent; +#[cfg(test)] pub(crate) fn parse_line( line: Result, emit: &mut F, @@ -7,22 +8,7 @@ pub(crate) fn parse_line( where F: FnMut(AiAgentStreamEvent), { - let line = match line { - Ok(line) => line, - Err(error) => { - emit(AiAgentStreamEvent::Error { - message: format!("Read error: {error}"), - }); - return None; - } - }; - - let trimmed = line.trim(); - if trimmed.is_empty() { - return None; - } - - serde_json::from_str::(trimmed).ok() + crate::cli_agent_runtime::parse_ai_agent_json_line(line, emit) } pub(crate) fn dispatch_event(json: &serde_json::Value, emit: &mut F)