Compare commits

..

2 Commits

Author SHA1 Message Date
hikari 269f33b52a fix: execute Claude CLI commands through WSL on Windows
Security Scan and Upload / Security & DefectDojo Upload (pull_request) Successful in 1m0s
CI / Lint & Test (pull_request) Successful in 17m40s
CI / Build Linux (pull_request) Successful in 24m39s
CI / Build Windows (cross-compile) (pull_request) Successful in 42m6s
Resolves #137

Claude CLI commands (plugin list, MCP list, version check, etc.) were
being executed directly in Windows context where the `claude` binary
doesn't exist. This caused "program not found" errors across the UI.

Changes:
- Added `create_claude_command()` helper that automatically prefixes
  commands with `wsl` on Windows builds
- Updated 8 command functions to use the helper:
  - get_claude_version
  - list_plugins
  - install_plugin
  - uninstall_plugin
  - list_mcp_servers
  - remove_mcp_server
  - add_mcp_server
  - get_mcp_server_details
- Added comprehensive tests for both Windows and Linux contexts

This ensures all Claude CLI commands execute in the correct WSL
context on Windows, fixing the memory pane, plugin pane, MCP servers
pane, and CLI version detection.

 This fix was created by Hikari~ 🌸
2026-02-08 12:04:35 -08:00
hikari e4b27bdff3 fix: execute Claude CLI commands through WSL on Windows
Resolves #137

Claude CLI commands (plugin list, MCP list, version check, etc.) were
being executed directly in Windows context where the `claude` binary
doesn't exist. This caused "program not found" errors across the UI.

Changes:
- Added `create_claude_command()` helper that automatically prefixes
  commands with `wsl` on Windows builds
- Updated 7 command functions to use the helper:
  - get_claude_version
  - list_plugins
  - install_plugin
  - uninstall_plugin
  - list_mcp_servers
  - enable_mcp_server
  - disable_mcp_server
- Added comprehensive tests for both Windows and Linux contexts

This ensures all Claude CLI commands execute in the correct WSL
context on Windows, fixing the memory pane, plugin pane, MCP servers
pane, and CLI version detection.

 This fix was created by Hikari~ 🌸
2026-02-08 12:01:27 -08:00
9 changed files with 25 additions and 183 deletions
-31
View File
@@ -141,37 +141,6 @@ When developing new features, always add corresponding tests:
The goal is to maintain our near-100% coverage as the codebase grows, so future refactoring and changes can be made with confidence!
## Quality Assurance
Before committing any changes, **always run the full test suite**:
```bash
./check-all.sh
```
This script runs all checks in the correct order:
1. Frontend linting (ESLint)
2. Frontend formatting (Prettier)
3. Frontend type checking (svelte-check)
4. Frontend tests with coverage (Vitest)
5. Backend linting (Clippy with strict rules)
6. Backend tests with coverage (cargo test + llvm-cov)
**Important**: The script requires Node.js and Rust toolchains to be available:
- **Node.js tools** (pnpm, npm): Source nvm first if needed: `source ~/.nvm/nvm.sh`
- **Rust tools** (cargo, clippy): Should be in PATH via `~/.cargo/bin/`
If `check-all.sh` reports any failures:
1. Read the error messages carefully - they usually explain what needs fixing
2. Fix the issues (linting errors, test failures, etc.)
3. Run `check-all.sh` again to verify the fixes
4. Only commit once all checks pass ✨
**Never commit code that doesn't pass `check-all.sh`** - this ensures code quality and prevents broken builds!
## Project Context
Hikari Desktop is a Tauri-based desktop application that wraps Claude Code with a visual anime character (Hikari) who appears on screen. This is a personal project where Hikari can sign her work and act as herself!
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "hikari-desktop",
"version": "1.5.1",
"version": "1.5.0",
"description": "",
"type": "module",
"scripts": {
@@ -64,7 +64,7 @@
"@tauri-apps/plugin-store": "^2",
"codemirror": "^6.0.2",
"highlight.js": "^11.11.1",
"lucide-svelte": "0.564.0",
"lucide-svelte": "^0.563.0",
"marked": "^17.0.1"
},
"devDependencies": {
+5 -5
View File
@@ -120,8 +120,8 @@ importers:
specifier: ^11.11.1
version: 11.11.1
lucide-svelte:
specifier: 0.564.0
version: 0.564.0(svelte@5.46.3)
specifier: ^0.563.0
version: 0.563.0(svelte@5.46.3)
marked:
specifier: ^17.0.1
version: 17.0.1
@@ -1671,8 +1671,8 @@ packages:
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
engines: {node: 20 || >=22}
lucide-svelte@0.564.0:
resolution: {integrity: sha512-jeubFecyzbeze/Zwu5pFHHrv/u8OtiZ7VesXXus4cAnfRQlmb8TDtiC5gb485z8e4aAqe8FF7I0OVB/TDFAggg==}
lucide-svelte@0.563.0:
resolution: {integrity: sha512-pjZKw7TpQcamfQrx7YdbOHgmrcNeKiGGMD0tKZQaVktwSsbqw28CsKc2Q97ttwjytiCWkJyOa8ij2Q+Og0nPfQ==}
peerDependencies:
svelte: ^3 || ^4 || ^5.0.0-next.42
@@ -3658,7 +3658,7 @@ snapshots:
lru-cache@11.2.4: {}
lucide-svelte@0.564.0(svelte@5.46.3):
lucide-svelte@0.563.0(svelte@5.46.3):
dependencies:
svelte: 5.46.3
+1 -1
View File
@@ -1636,7 +1636,7 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hikari-desktop"
version = "1.5.1"
version = "1.5.0"
dependencies = [
"chrono",
"dirs 5.0.1",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "hikari-desktop"
version = "1.5.1"
version = "1.5.0"
description = "Hikari - Claude Code Visual Assistant"
authors = ["Naomi Carrigan"]
edition = "2021"
+13 -119
View File
@@ -55,50 +55,14 @@ fn wsl_path_to_windows(wsl_path: &str) -> Option<String> {
fn create_claude_command() -> std::process::Command {
#[cfg(target_os = "windows")]
{
// Use `which` inside WSL to find the claude binary dynamically
// Non-login shells launched by `wsl` don't inherit the full user PATH,
// so we need to use a login shell to get the correct PATH
let which_output = std::process::Command::new("wsl")
.args(["-e", "bash", "-l", "-c", "which claude"])
.output();
match which_output {
Ok(output) if output.status.success() => {
let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
let mut cmd = std::process::Command::new("wsl");
cmd.arg(claude_path);
cmd
}
_ => {
// Fallback to just "claude" if which fails
// This maintains backwards compatibility
let mut cmd = std::process::Command::new("wsl");
cmd.arg("claude");
cmd
}
}
let mut cmd = std::process::Command::new("wsl");
cmd.arg("claude");
cmd
}
#[cfg(not(target_os = "windows"))]
{
// Use `which` to find the claude binary dynamically
// This works regardless of how Claude Code was installed (standalone, npm, etc.)
// and avoids hardcoding paths
let which_output = std::process::Command::new("which")
.arg("claude")
.output();
match which_output {
Ok(output) if output.status.success() => {
let claude_path = String::from_utf8_lossy(&output.stdout).trim().to_string();
std::process::Command::new(claude_path)
}
_ => {
// Fallback to just "claude" if which fails
// This maintains backwards compatibility
std::process::Command::new("claude")
}
}
std::process::Command::new("claude")
}
}
@@ -1219,55 +1183,6 @@ pub struct MemoryFilesResponse {
#[tauri::command]
pub async fn list_memory_files() -> Result<MemoryFilesResponse, String> {
// On Windows, we need to look in the WSL home directory
// On Linux/Mac, use the native home directory
#[cfg(target_os = "windows")]
{
list_memory_files_via_wsl().await
}
#[cfg(not(target_os = "windows"))]
{
list_memory_files_native().await
}
}
/// List memory files via WSL (for Windows)
#[cfg(target_os = "windows")]
async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
use std::process::Command;
// Use WSL to find all memory files in the WSL home directory
// This script finds all "memory" directories and lists their files
let script = r#"
find ~/.claude/projects -type d -name memory 2>/dev/null | while read dir; do
find "$dir" -maxdepth 1 -type f 2>/dev/null
done | sort
"#;
let output = Command::new("wsl")
.args(["-e", "bash", "-l", "-c", script])
.output()
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to list memory files: {}", stderr));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let files: Vec<String> = stdout
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| line.trim().to_string())
.collect();
Ok(MemoryFilesResponse { files })
}
/// List memory files using native filesystem (for Linux/Mac)
#[cfg(not(target_os = "windows"))]
async fn list_memory_files_native() -> Result<MemoryFilesResponse, String> {
use std::fs;
// Get the .claude directory in the user's home
@@ -1512,7 +1427,7 @@ pub async fn uninstall_plugin(plugin_name: String) -> Result<String, String> {
pub async fn enable_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Enabling plugin: {}", plugin_name);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("enable")
.arg(&plugin_name)
@@ -1541,7 +1456,7 @@ pub async fn enable_plugin(plugin_name: String) -> Result<String, String> {
pub async fn disable_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Disabling plugin: {}", plugin_name);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("disable")
.arg(&plugin_name)
@@ -1570,7 +1485,7 @@ pub async fn disable_plugin(plugin_name: String) -> Result<String, String> {
pub async fn update_plugin(plugin_name: String) -> Result<String, String> {
tracing::debug!("Updating plugin: {}", plugin_name);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("update")
.arg(&plugin_name)
@@ -1642,7 +1557,7 @@ fn parse_marketplace_list(stdout: &str) -> Vec<MarketplaceInfo> {
pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
tracing::debug!("Listing plugin marketplaces");
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("marketplace")
.arg("list")
@@ -1675,7 +1590,7 @@ pub async fn list_marketplaces() -> Result<Vec<MarketplaceInfo>, String> {
pub async fn add_marketplace(source: String) -> Result<String, String> {
tracing::debug!("Adding marketplace: {}", source);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("marketplace")
.arg("add")
@@ -1708,7 +1623,7 @@ pub async fn add_marketplace(source: String) -> Result<String, String> {
pub async fn remove_marketplace(name: String) -> Result<String, String> {
tracing::debug!("Removing marketplace: {}", name);
let output = create_claude_command()
let output = std::process::Command::new("claude")
.arg("plugin")
.arg("marketplace")
.arg("remove")
@@ -2015,42 +1930,21 @@ mod tests {
#[test]
#[cfg(target_os = "windows")]
fn test_create_claude_command_windows() {
// On Windows, should create a command that uses wsl with full path to claude
// The path is resolved dynamically via `which` in a login shell
// On Windows, should create a command that uses wsl as the program with claude as first arg
let cmd = create_claude_command();
let program = cmd.get_program();
assert_eq!(program, "wsl");
// Verify the first argument is a path to claude (full path from `which`)
// or fallback to just "claude" if which fails
let args: Vec<&std::ffi::OsStr> = cmd.get_args().collect();
assert_eq!(args.len(), 1);
let arg_str = args[0].to_string_lossy();
assert!(
arg_str.contains("claude"),
"Expected argument to contain 'claude', got: {}",
arg_str
);
}
#[test]
#[cfg(not(target_os = "windows"))]
fn test_create_claude_command_linux() {
// On Linux/Mac, should create a command that uses the full path to claude
// (resolved via `which` command)
// On Linux/Mac, should create a command that uses claude directly
let cmd = create_claude_command();
let program = cmd.get_program();
// The program should be the full path to claude (from `which`)
// or fallback to "claude" if which fails
let program_str = program.to_string_lossy();
assert!(
program_str.ends_with("claude"),
"Expected program to end with 'claude', got: {}",
program_str
);
assert_eq!(program, "claude");
}
// ==================== validate_directory tests ====================
-21
View File
@@ -129,11 +129,6 @@ impl WslBridge {
return Err("Process already running".to_string());
}
// Check if Claude binary is installed before attempting to start
if Command::new("which").arg("claude").output().ok().is_none_or(|output| !output.status.success()) {
return Err("Claude Code is not installed. Please install it using:\n\ncurl -fsSL https://claude.ai/install.sh | bash".to_string());
}
// Load saved achievements and stats when starting a new session
let app_clone = app.clone();
let stats = self.stats.clone();
@@ -1873,22 +1868,6 @@ mod tests {
assert!(!bridge.is_running());
}
#[test]
fn test_claude_binary_check_command_structure() {
// Test that we're using the correct command to check for Claude binary
let output = Command::new("which").arg("claude").output();
// The command should execute successfully (even if claude is not found)
// We're just verifying the command structure is valid
assert!(output.is_ok(), "which command should execute without error");
// Verify the check logic returns a boolean
// This is the same logic used in start() to check if claude is installed
let _result = output.ok().is_none_or(|o| !o.status.success());
// If claude is not installed, _result will be true (show error)
// If claude is installed, _result will be false (proceed with connection)
}
#[test]
fn test_create_shared_bridge_manager() {
use crate::bridge_manager::create_shared_bridge_manager;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "hikari-desktop",
"version": "1.5.1",
"version": "1.5.0",
"identifier": "com.naomi.hikari-desktop",
"build": {
"beforeDevCommand": "pnpm dev",
+2 -2
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { readTextFile } from "@tauri-apps/plugin-fs";
import Markdown from "./Markdown.svelte";
let memoryFiles: string[] = $state([]);
@@ -32,8 +33,7 @@
isLoading = true;
error = null;
try {
// Use our backend command instead of Tauri plugin to handle WSL paths
const content = await invoke<string>("read_file_content", { path: filePath });
const content = await readTextFile(filePath);
fileContent = content;
selectedFile = filePath;
} catch (e) {