From 40544fc551cf0d54e2df5f7ef556dc11558be07b Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 28 Apr 2026 23:27:29 +0200 Subject: [PATCH] fix: detect nvm ai agent binaries --- docs/ABSTRACTIONS.md | 2 +- docs/ARCHITECTURE.md | 2 +- src-tauri/src/ai_agents.rs | 33 +++++++++++++++++++++++++++++++-- src-tauri/src/claude_cli.rs | 33 +++++++++++++++++++++++++++++++-- 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 05feb950..5450003d 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -705,7 +705,7 @@ Tolaria tracks managed vault-level AI guidance separately from normal note conte `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 Windows `.exe` and npm/pnpm/Scoop shim paths, as valid CLI-agent sources +- 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 - Persists dismissal locally once the user continues ### Remote Git Operations diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c33694ad..3d561aaa 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -229,7 +229,7 @@ Full agent mode — spawns the selected local CLI agent as a subprocess with too 3. **Agent adapters** — Claude Code still uses `claude_cli.rs` with `acceptEdits`, strict Tolaria MCP config, a file/search-only built-in tool list, hidden Windows subprocess launches, and closed stdin for print-mode subprocesses so Windows launches receive EOF; Codex runs through `codex --sandbox workspace-write --ask-for-approval never exec --json`; OpenCode runs through `opencode run --format json`; Pi runs through `pi --mode json --no-session` with `npm:pi-mcp-adapter`. 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` -CLI-agent availability intentionally does not depend only on the desktop app's inherited `PATH`. The detectors check the current process path, the user's login shell, and supported local/toolchain install locations such as native `~/.local/bin`, local `~/.claude/local`, Mise/asdf shims, npm-global, Homebrew, Windows `%APPDATA%\npm`/pnpm/Scoop shims, Windows `.exe` launchers, and the macOS Codex app resource path so first-run onboarding works on fresh macOS and Windows installs. +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. #### Agent Event Flow diff --git a/src-tauri/src/ai_agents.rs b/src-tauri/src/ai_agents.rs index 7dbc0cc2..3b63a479 100644 --- a/src-tauri/src/ai_agents.rs +++ b/src-tauri/src/ai_agents.rs @@ -229,7 +229,7 @@ fn codex_binary_candidates() -> Vec { } fn codex_binary_candidates_for_home(home: &Path) -> Vec { - vec![ + let mut candidates = vec![ home.join(".local/bin/codex"), home.join(".codex/bin/codex"), home.join(".local/share/mise/shims/codex"), @@ -240,7 +240,24 @@ fn codex_binary_candidates_for_home(home: &Path) -> Vec { 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 { @@ -585,6 +602,18 @@ mod tests { } } + #[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(); diff --git a/src-tauri/src/claude_cli.rs b/src-tauri/src/claude_cli.rs index b8d5d6cf..6e1f685b 100644 --- a/src-tauri/src/claude_cli.rs +++ b/src-tauri/src/claude_cli.rs @@ -148,7 +148,7 @@ fn claude_binary_candidates() -> Vec { } fn claude_binary_candidates_for_home(home: &Path) -> Vec { - vec![ + let mut candidates = vec![ home.join(".local/bin/claude"), home.join(".local/bin/claude.exe"), home.join(".claude/local/claude"), @@ -170,7 +170,24 @@ fn claude_binary_candidates_for_home(home: &Path) -> Vec { home.join("scoop/shims/claude.exe"), PathBuf::from("/opt/homebrew/bin/claude"), PathBuf::from("/usr/local/bin/claude"), - ] + ]; + candidates.extend(nvm_node_binary_candidates_for_home(home, "claude")); + 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 { @@ -658,6 +675,18 @@ mod tests { } } + #[test] + fn claude_binary_candidates_include_nvm_managed_node_installs() { + let home = tempfile::tempdir().unwrap(); + let claude = home.path().join(".nvm/versions/node/v22.12.0/bin/claude"); + std::fs::create_dir_all(claude.parent().unwrap()).unwrap(); + std::fs::write(&claude, "#!/bin/sh\n").unwrap(); + + let candidates = claude_binary_candidates_for_home(home.path()); + + assert!(candidates.contains(&claude), "missing {}", claude.display()); + } + #[test] fn build_mcp_config_is_valid_json() { if let Ok(config_str) = build_mcp_config("/tmp/test-vault") {