generated from nhcarrigan/template
feat: Claude CLI 2.1.50–2.1.53 audit (#171)
## Summary This PR covers the full audit of Claude CLI changes from 2.1.50 to 2.1.53, plus a batch of bug fixes, new features, and maintenance work identified during that review. ### New Features - **Workspace trust gate** — detects hooks, MCP servers, and custom commands in a workspace before connecting; persists trust decisions so users aren't prompted repeatedly - **Custom background image** — users can set a background image with configurable opacity; character panel and compact mode go transparent when active - **Draggable tab reordering** — conversation tabs can be reordered via pointer-event drag-and-drop (HTML5 drag is intercepted by Tauri/WebView2, so pointer events are used instead) - **Org UUID in account info** — exposes the org UUID from Claude auth status ### Bug Fixes - **Unread dot false positives** — initialise unread counts on mount to prevent all tabs showing the blue dot after toggling the file editor (Closes #164) - **Watchdog for hung WSL bridge** — detects connections that never receive `system:init` and kills the stale process after 1 minute (Closes #166) - **Suppress terminal window flash on Windows** — applies `CREATE_NO_WINDOW` to all subprocesses via a `HideWindow` trait extension (Closes #165) - **HTML escaping in markdown renderer** — escape `<` and `>` in `codespan` and `html` renderer callbacks to prevent raw HTML injection (Closes #169) ### Maintenance - Verify stream-JSON handles tool results above the 50K threshold correctly (Closes #162) - Reviewed hook security fixes from CLI 2.1.51 — not applicable to our setup (Closes #163) - Expose org UUID from `claude auth status` (Closes #160) - Clean up Svelte and Vite build warnings (`a11y_click_events_have_key_events`, `state_referenced_locally`, `non_reactive_update`, `codeSplitting`, chunk size, CodeMirror dynamic import) - Update all npm dependencies to latest compatible versions with exact pinning (Closes #81, Closes #82, Closes #83, Closes #84, Closes #85, Closes #86, Closes #87, Closes #90, Closes #91, Closes #93, Closes #94, Closes #95, Closes #96, Closes #97, Closes #98, Closes #99, Closes #101, Closes #141, Closes #142, Closes #143, Closes #145, Closes #146, Closes #147) - Run `cargo update` to bring Cargo.lock up to date ### Closes Closes #160 Closes #162 Closes #163 Closes #164 Closes #165 Closes #166 Closes #167 Closes #168 Closes #169 Closes #81 Closes #82 Closes #83 Closes #84 Closes #85 Closes #86 Closes #87 Closes #90 Closes #91 Closes #93 Closes #94 Closes #95 Closes #96 Closes #97 Closes #98 Closes #99 Closes #101 Closes #141 Closes #142 Closes #143 Closes #145 Closes #146 Closes #147 ✨ This PR was created with help from Hikari~ 🌸 Reviewed-on: #171 Co-authored-by: Hikari <hikari@nhcarrigan.com> Co-committed-by: Hikari <hikari@nhcarrigan.com>
This commit was merged in pull request #171.
This commit is contained in:
+152
-2
@@ -7,6 +7,7 @@ use tauri_plugin_store::StoreExt;
|
||||
use crate::achievements::{get_achievement_info, load_achievements, AchievementUnlockedEvent};
|
||||
use crate::bridge_manager::SharedBridgeManager;
|
||||
use crate::config::{ClaudeStartOptions, HikariConfig};
|
||||
use crate::process_ext::HideWindow;
|
||||
use crate::stats::UsageStats;
|
||||
use crate::temp_manager::SharedTempFileManager;
|
||||
|
||||
@@ -59,6 +60,7 @@ fn create_claude_command() -> std::process::Command {
|
||||
// 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")
|
||||
.hide_window()
|
||||
.args(["-e", "bash", "-l", "-c", "which claude"])
|
||||
.output();
|
||||
|
||||
@@ -66,6 +68,7 @@ fn create_claude_command() -> std::process::Command {
|
||||
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.hide_window();
|
||||
cmd.arg(claude_path);
|
||||
cmd
|
||||
}
|
||||
@@ -73,6 +76,7 @@ fn create_claude_command() -> std::process::Command {
|
||||
// Fallback to just "claude" if which fails
|
||||
// This maintains backwards compatibility
|
||||
let mut cmd = std::process::Command::new("wsl");
|
||||
cmd.hide_window();
|
||||
cmd.arg("claude");
|
||||
cmd
|
||||
}
|
||||
@@ -85,18 +89,23 @@ fn create_claude_command() -> std::process::Command {
|
||||
// This works regardless of how Claude Code was installed (standalone, npm, etc.)
|
||||
// and avoids hardcoding paths
|
||||
let which_output = std::process::Command::new("which")
|
||||
.hide_window()
|
||||
.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)
|
||||
let mut cmd = std::process::Command::new(claude_path);
|
||||
cmd.hide_window();
|
||||
cmd
|
||||
}
|
||||
_ => {
|
||||
// Fallback to just "claude" if which fails
|
||||
// This maintains backwards compatibility
|
||||
std::process::Command::new("claude")
|
||||
let mut cmd = std::process::Command::new("claude");
|
||||
cmd.hide_window();
|
||||
cmd
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,6 +343,121 @@ pub async fn answer_question(
|
||||
manager.send_tool_result(&conversation_id, &tool_use_id, answers)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct WorkspaceHookInfo {
|
||||
pub has_concerns: bool,
|
||||
pub hook_types: Vec<String>,
|
||||
pub mcp_servers: Vec<String>,
|
||||
pub custom_commands: Vec<String>,
|
||||
}
|
||||
|
||||
/// Check whether a working directory has Claude Code hooks, MCP servers, or custom commands.
|
||||
///
|
||||
/// Hikari Desktop runs Claude in `--output-format stream-json` (non-interactive mode),
|
||||
/// which bypasses Claude's own workspace trust dialog. We therefore check for these
|
||||
/// ourselves so the frontend can show its own trust gate before launching.
|
||||
#[tauri::command]
|
||||
pub async fn check_workspace_hooks(working_dir: String) -> WorkspaceHookInfo {
|
||||
let use_wsl = cfg!(windows) && working_dir.starts_with('/');
|
||||
|
||||
let settings_paths = [
|
||||
format!("{}/.claude/settings.json", working_dir),
|
||||
format!("{}/.claude/settings.local.json", working_dir),
|
||||
];
|
||||
|
||||
let mut all_hook_types: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
|
||||
let mut all_mcp_servers: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
|
||||
|
||||
for path in &settings_paths {
|
||||
let content = if use_wsl {
|
||||
match read_file_via_wsl(path).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
}
|
||||
} else {
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
}
|
||||
};
|
||||
|
||||
let settings: serde_json::Value = match serde_json::from_str(&content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if let Some(hooks) = settings.get("hooks").and_then(|h| h.as_object()) {
|
||||
for key in hooks.keys() {
|
||||
all_hook_types.insert(key.clone());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(servers) = settings.get("mcpServers").and_then(|s| s.as_object()) {
|
||||
for key in servers.keys() {
|
||||
all_mcp_servers.insert(key.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let custom_commands = list_workspace_commands(&working_dir, use_wsl).await;
|
||||
let hook_types: Vec<String> = all_hook_types.into_iter().collect();
|
||||
let mcp_servers: Vec<String> = all_mcp_servers.into_iter().collect();
|
||||
let has_concerns = !hook_types.is_empty() || !mcp_servers.is_empty() || !custom_commands.is_empty();
|
||||
|
||||
WorkspaceHookInfo {
|
||||
has_concerns,
|
||||
hook_types,
|
||||
mcp_servers,
|
||||
custom_commands,
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_workspace_commands(working_dir: &str, use_wsl: bool) -> Vec<String> {
|
||||
let commands_dir = format!("{}/.claude/commands", working_dir);
|
||||
|
||||
if use_wsl {
|
||||
let script = format!(
|
||||
"if [ -d '{0}' ]; then for f in '{0}'/*.md; do [ -f \"$f\" ] && basename \"$f\" .md; done; fi",
|
||||
commands_dir
|
||||
);
|
||||
let Ok(output) = std::process::Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "sh", "-c", &script])
|
||||
.output()
|
||||
else {
|
||||
return vec![];
|
||||
};
|
||||
String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
} else {
|
||||
let dir = std::path::Path::new(&commands_dir);
|
||||
if !dir.exists() {
|
||||
return vec![];
|
||||
}
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return vec![];
|
||||
};
|
||||
let mut names: Vec<String> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
|
||||
})
|
||||
.filter_map(|e| {
|
||||
e.path()
|
||||
.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
})
|
||||
.collect();
|
||||
names.sort();
|
||||
names
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_skills() -> Result<Vec<String>, String> {
|
||||
// On Windows, we need to use WSL to access the skills directory
|
||||
@@ -381,6 +505,7 @@ async fn list_skills_via_wsl() -> Result<Vec<String>, String> {
|
||||
|
||||
// Use WSL to list directories in ~/.claude/skills that contain SKILL.md
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args([
|
||||
"-e",
|
||||
"sh",
|
||||
@@ -680,6 +805,7 @@ async fn list_directory_via_wsl(path: &str) -> Result<Vec<FileEntry>, String> {
|
||||
);
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "sh", "-c", &script])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -742,6 +868,7 @@ async fn read_file_via_wsl(path: &str) -> Result<String, String> {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "cat", path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -773,6 +900,7 @@ async fn write_file_via_wsl(path: &str, content: &str) -> Result<(), String> {
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
let mut child = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "sh", "-c", &format!("cat > '{}'", path)])
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()
|
||||
@@ -821,6 +949,7 @@ async fn create_file_via_wsl(path: &str) -> Result<(), String> {
|
||||
|
||||
// Check if file exists first
|
||||
let check = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-e", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -830,6 +959,7 @@ async fn create_file_via_wsl(path: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "touch", path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -870,6 +1000,7 @@ async fn create_directory_via_wsl(path: &str) -> Result<(), String> {
|
||||
|
||||
// Check if directory exists first
|
||||
let check = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-e", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -879,6 +1010,7 @@ async fn create_directory_via_wsl(path: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "mkdir", "-p", path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -923,6 +1055,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
|
||||
|
||||
// Check if path exists
|
||||
let check_exists = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-e", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -933,6 +1066,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
|
||||
|
||||
// Check if path is a directory
|
||||
let check_dir = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-d", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -942,6 +1076,7 @@ async fn delete_file_via_wsl(path: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "rm", path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -986,6 +1121,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
|
||||
|
||||
// Check if path exists
|
||||
let check_exists = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-e", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -996,6 +1132,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
|
||||
|
||||
// Check if path is a directory
|
||||
let check_dir = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-d", path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -1005,6 +1142,7 @@ async fn delete_directory_via_wsl(path: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "rm", "-rf", path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -1050,6 +1188,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin
|
||||
|
||||
// Check if old path exists
|
||||
let check_old = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-e", old_path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -1060,6 +1199,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin
|
||||
|
||||
// Check if new path already exists
|
||||
let check_new = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "test", "-e", new_path])
|
||||
.status()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -1069,6 +1209,7 @@ async fn rename_path_via_wsl(old_path: &str, new_path: &str) -> Result<(), Strin
|
||||
}
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "mv", old_path, new_path])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -1246,6 +1387,7 @@ async fn list_memory_files_via_wsl() -> Result<MemoryFilesResponse, String> {
|
||||
"#;
|
||||
|
||||
let output = Command::new("wsl")
|
||||
.hide_window()
|
||||
.args(["-e", "bash", "-l", "-c", script])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute WSL command: {}", e))?;
|
||||
@@ -1366,6 +1508,7 @@ pub async fn get_claude_version() -> Result<String, String> {
|
||||
pub struct ClaudeAuthStatus {
|
||||
pub is_logged_in: bool,
|
||||
pub email: Option<String>,
|
||||
pub org_id: Option<String>,
|
||||
pub org_name: Option<String>,
|
||||
pub api_key_source: Option<String>,
|
||||
pub api_provider: Option<String>,
|
||||
@@ -1396,6 +1539,11 @@ pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let org_id = json
|
||||
.get("orgId")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from);
|
||||
|
||||
let org_name = json
|
||||
.get("orgName")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -1420,6 +1568,7 @@ pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
|
||||
Ok(ClaudeAuthStatus {
|
||||
is_logged_in,
|
||||
email,
|
||||
org_id,
|
||||
org_name,
|
||||
api_key_source,
|
||||
api_provider,
|
||||
@@ -1436,6 +1585,7 @@ pub async fn get_auth_status() -> Result<ClaudeAuthStatus, String> {
|
||||
Ok(ClaudeAuthStatus {
|
||||
is_logged_in,
|
||||
email: None,
|
||||
org_id: None,
|
||||
org_name: None,
|
||||
api_key_source: None,
|
||||
api_provider: None,
|
||||
|
||||
Reference in New Issue
Block a user