diff --git a/package.json b/package.json index d5ca47d..31c3337 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,8 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "^2", "@tauri-apps/plugin-opener": "^2", - "@tauri-apps/plugin-shell": "^2.3.4" + "@tauri-apps/plugin-shell": "^2.3.4", + "@tauri-apps/plugin-store": "^2" }, "devDependencies": { "@eslint/js": "^9.39.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ca62843..c71d0ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@tauri-apps/plugin-shell': specifier: ^2.3.4 version: 2.3.4 + '@tauri-apps/plugin-store': + specifier: ^2 + version: 2.4.2 devDependencies: '@eslint/js': specifier: ^9.39.2 @@ -729,6 +732,9 @@ packages: '@tauri-apps/plugin-shell@2.3.4': resolution: {integrity: sha512-ktsRWf8wHLD17aZEyqE8c5x98eNAuTizR1FSX475zQ4TxaiJnhwksLygQz+AGwckJL5bfEP13nWrlTNQJUpKpA==} + '@tauri-apps/plugin-store@2.4.2': + resolution: {integrity: sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==} + '@testing-library/dom@10.4.1': resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} engines: {node: '>=18'} @@ -2265,6 +2271,10 @@ snapshots: dependencies: '@tauri-apps/api': 2.9.1 + '@tauri-apps/plugin-store@2.4.2': + dependencies: + '@tauri-apps/api': 2.9.1 + '@testing-library/dom@10.4.1': dependencies: '@babel/code-frame': 7.28.6 diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 3e6fcfa..b3df986 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1403,6 +1403,8 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-opener", "tauri-plugin-shell", + "tauri-plugin-store", + "tempfile", "tokio", "uuid", ] @@ -3761,6 +3763,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-store" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca1a8ff83c269b115e98726ffc13f9e548a10161544a92ad121d6d0a96e16ea" +dependencies = [ + "dunce", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "tokio", + "tracing", +] + [[package]] name = "tauri-runtime" version = "2.9.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 1dd82e8..ffe1ec5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,4 +22,6 @@ serde_json = "1" tokio = { version = "1", features = ["full"] } parking_lot = "0.12" uuid = { version = "1", features = ["v4"] } +tauri-plugin-store = "2.4.2" +tempfile = "3" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 8c169b6..7532af1 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1,16 +1,19 @@ use tauri::{AppHandle, State}; +use tauri_plugin_store::StoreExt; +use crate::config::{ClaudeStartOptions, HikariConfig}; use crate::wsl_bridge::SharedBridge; +const CONFIG_STORE_KEY: &str = "config"; + #[tauri::command] pub async fn start_claude( app: AppHandle, bridge: State<'_, SharedBridge>, - working_dir: String, - allowed_tools: Option>, + options: ClaudeStartOptions, ) -> Result<(), String> { let mut bridge = bridge.lock(); - bridge.start(app, &working_dir, allowed_tools.unwrap_or_default()) + bridge.start(app, options) } #[tauri::command] @@ -42,3 +45,30 @@ pub async fn get_working_directory(bridge: State<'_, SharedBridge>) -> Result Result { Ok("/home".to_string()) } + +#[tauri::command] +pub async fn get_config(app: AppHandle) -> Result { + let store = app + .store("hikari-config.json") + .map_err(|e| e.to_string())?; + + match store.get(CONFIG_STORE_KEY) { + Some(value) => { + serde_json::from_value(value.clone()).map_err(|e| e.to_string()) + } + None => Ok(HikariConfig::default()), + } +} + +#[tauri::command] +pub async fn save_config(app: AppHandle, config: HikariConfig) -> Result<(), String> { + let store = app + .store("hikari-config.json") + .map_err(|e| e.to_string())?; + + let value = serde_json::to_value(&config).map_err(|e| e.to_string())?; + store.set(CONFIG_STORE_KEY, value); + store.save().map_err(|e| e.to_string())?; + + Ok(()) +} diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs new file mode 100644 index 0000000..29fc620 --- /dev/null +++ b/src-tauri/src/config.rs @@ -0,0 +1,96 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ClaudeStartOptions { + #[serde(default)] + pub working_dir: String, + + #[serde(default)] + pub model: Option, + + #[serde(default)] + pub api_key: Option, + + #[serde(default)] + pub custom_instructions: Option, + + #[serde(default)] + pub mcp_servers_json: Option, + + #[serde(default)] + pub allowed_tools: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct HikariConfig { + #[serde(default)] + pub model: Option, + + #[serde(default)] + pub api_key: Option, + + #[serde(default)] + pub custom_instructions: Option, + + #[serde(default)] + pub mcp_servers_json: Option, + + #[serde(default)] + pub auto_granted_tools: Vec, + + #[serde(default)] + pub theme: Theme, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum Theme { + #[default] + Dark, + Light, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_default_config() { + let config = HikariConfig::default(); + assert!(config.model.is_none()); + assert!(config.api_key.is_none()); + assert!(config.custom_instructions.is_none()); + assert!(config.mcp_servers_json.is_none()); + assert!(config.auto_granted_tools.is_empty()); + assert_eq!(config.theme, Theme::Dark); + } + + #[test] + fn test_config_serialization() { + let config = HikariConfig { + model: Some("claude-sonnet-4-20250514".to_string()), + api_key: None, + custom_instructions: Some("Be helpful".to_string()), + mcp_servers_json: None, + auto_granted_tools: vec!["Read".to_string(), "Glob".to_string()], + theme: Theme::Light, + }; + + let json = serde_json::to_string(&config).unwrap(); + let deserialized: HikariConfig = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.model, config.model); + assert_eq!(deserialized.custom_instructions, config.custom_instructions); + assert_eq!(deserialized.auto_granted_tools, config.auto_granted_tools); + assert_eq!(deserialized.theme, Theme::Light); + } + + #[test] + fn test_theme_serialization() { + let dark = Theme::Dark; + let light = Theme::Light; + + assert_eq!(serde_json::to_string(&dark).unwrap(), "\"dark\""); + assert_eq!(serde_json::to_string(&light).unwrap(), "\"light\""); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4ec5ad2..d529cbc 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod commands; +mod config; mod types; mod wsl_bridge; @@ -13,6 +14,7 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_store::Builder::new().build()) .manage(bridge) .invoke_handler(tauri::generate_handler![ start_claude, @@ -21,6 +23,8 @@ pub fn run() { is_claude_running, get_working_directory, select_wsl_directory, + get_config, + save_config, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/wsl_bridge.rs b/src-tauri/src/wsl_bridge.rs index a9916f4..09b9bcb 100644 --- a/src-tauri/src/wsl_bridge.rs +++ b/src-tauri/src/wsl_bridge.rs @@ -4,10 +4,12 @@ use std::process::{Child, ChildStdin, Command, Stdio}; use std::sync::Arc; use std::thread; use tauri::{AppHandle, Emitter}; +use tempfile::NamedTempFile; #[cfg(target_os = "windows")] use std::os::windows::process::CommandExt; +use crate::config::ClaudeStartOptions; use crate::types::{CharacterState, ClaudeMessage, ConnectionStatus, ContentBlock, StateChangeEvent, OutputEvent, PermissionPromptEvent}; const SEARCH_TOOLS: [&str; 5] = ["Read", "Glob", "Grep", "WebSearch", "WebFetch"]; @@ -69,6 +71,7 @@ pub struct WslBridge { stdin: Option, working_directory: String, session_id: Option, + mcp_config_file: Option, } impl WslBridge { @@ -78,22 +81,50 @@ impl WslBridge { stdin: None, working_directory: String::new(), session_id: None, + mcp_config_file: None, } } - pub fn start(&mut self, app: AppHandle, working_dir: &str, allowed_tools: Vec) -> Result<(), String> { + pub fn start(&mut self, app: AppHandle, options: ClaudeStartOptions) -> Result<(), String> { if self.process.is_some() { return Err("Process already running".to_string()); } - self.working_directory = working_dir.to_string(); + let working_dir = &options.working_dir; + self.working_directory = working_dir.clone(); emit_connection_status(&app, ConnectionStatus::Connecting); + // Create temp file for MCP config if provided + let mcp_config_path = if let Some(ref mcp_json) = options.mcp_servers_json { + if !mcp_json.trim().is_empty() { + // Validate JSON before writing + serde_json::from_str::(mcp_json) + .map_err(|e| format!("Invalid MCP servers JSON: {}", e))?; + + let mut temp_file = NamedTempFile::new() + .map_err(|e| format!("Failed to create temp file for MCP config: {}", e))?; + temp_file + .write_all(mcp_json.as_bytes()) + .map_err(|e| format!("Failed to write MCP config: {}", e))?; + temp_file + .flush() + .map_err(|e| format!("Failed to flush MCP config: {}", e))?; + + let path = temp_file.path().to_string_lossy().to_string(); + self.mcp_config_file = Some(temp_file); + Some(path) + } else { + None + } + } else { + None + }; + // Detect if we're running inside WSL or on Windows let is_wsl = detect_wsl(); eprintln!("[DEBUG] is_wsl: {}", is_wsl); - eprintln!("[DEBUG] allowed_tools: {:?}", allowed_tools); + eprintln!("[DEBUG] options: {:?}", options); let mut command = if is_wsl { // Running inside WSL - call claude directly @@ -111,12 +142,39 @@ impl WslBridge { "--verbose", ]); + // Add model if specified + if let Some(ref model) = options.model { + if !model.is_empty() { + cmd.args(["--model", model]); + } + } + // Add allowed tools if any - for tool in &allowed_tools { + for tool in &options.allowed_tools { cmd.args(["--allowedTools", tool]); } + // Add custom instructions as system prompt if specified + if let Some(ref instructions) = options.custom_instructions { + if !instructions.is_empty() { + cmd.args(["--system-prompt", instructions]); + } + } + + // Add MCP config if provided + if let Some(ref mcp_path) = mcp_config_path { + cmd.args(["--mcp-config", mcp_path]); + } + cmd.current_dir(working_dir); + + // Set API key as environment variable if specified + if let Some(ref api_key) = options.api_key { + if !api_key.is_empty() { + cmd.env("ANTHROPIC_API_KEY", api_key); + } + } + cmd } else { // Running on Windows - use wsl with bash login shell to ensure PATH is loaded @@ -125,15 +183,45 @@ impl WslBridge { // Build the claude command with all arguments let mut claude_cmd = format!( - "cd '{}' && claude --output-format stream-json --input-format stream-json --verbose", + "cd '{}' && ", working_dir ); + // Set API key as environment variable if specified + if let Some(ref api_key) = options.api_key { + if !api_key.is_empty() { + claude_cmd.push_str(&format!("ANTHROPIC_API_KEY='{}' ", api_key)); + } + } + + claude_cmd.push_str("claude --output-format stream-json --input-format stream-json --verbose"); + + // Add model if specified + if let Some(ref model) = options.model { + if !model.is_empty() { + claude_cmd.push_str(&format!(" --model '{}'", model)); + } + } + // Add allowed tools if any - for tool in &allowed_tools { + for tool in &options.allowed_tools { claude_cmd.push_str(&format!(" --allowedTools '{}'", tool)); } + // Add custom instructions as system prompt if specified + if let Some(ref instructions) = options.custom_instructions { + if !instructions.is_empty() { + // Escape single quotes in instructions + let escaped = instructions.replace('\'', "'\\''"); + claude_cmd.push_str(&format!(" --system-prompt '{}'", escaped)); + } + } + + // Add MCP config if provided + if let Some(ref mcp_path) = mcp_config_path { + claude_cmd.push_str(&format!(" --mcp-config '{}'", mcp_path)); + } + // Use bash -lc to load login profile (ensures PATH includes claude) cmd.args(["-e", "bash", "-lc", &claude_cmd]); @@ -212,6 +300,7 @@ impl WslBridge { } self.stdin = None; self.session_id = None; + self.mcp_config_file = None; // Temp file is automatically deleted when dropped emit_connection_status(app, ConnectionStatus::Disconnected); } diff --git a/src/app.css b/src/app.css index baf7aef..7e6aa5c 100644 --- a/src/app.css +++ b/src/app.css @@ -1,9 +1,11 @@ @import "tailwindcss"; -:root { +:root, +[data-theme="dark"] { --bg-primary: #1a1a2e; --bg-secondary: #16213e; --bg-terminal: #0f0f1a; + --bg-hover: #2a2a4a; --accent-primary: #e94560; --accent-secondary: #ff6b9d; --text-primary: #ffffff; @@ -11,6 +13,18 @@ --border-color: #2a2a4a; } +[data-theme="light"] { + --bg-primary: #f8f9fa; + --bg-secondary: #ffffff; + --bg-terminal: #f1f3f4; + --bg-hover: #e8e8e8; + --accent-primary: #e94560; + --accent-secondary: #ff6b9d; + --text-primary: #1a1a2e; + --text-secondary: #5a5a7a; + --border-color: #d0d0e0; +} + html, body { margin: 0; diff --git a/src/lib/components/ConfigSidebar.svelte b/src/lib/components/ConfigSidebar.svelte new file mode 100644 index 0000000..bb09d8c --- /dev/null +++ b/src/lib/components/ConfigSidebar.svelte @@ -0,0 +1,368 @@ + + + +{#if isOpen} +
e.key === "Escape" && configStore.closeSidebar()} + role="button" + tabindex="-1" + aria-label="Close sidebar" + >
+{/if} + + + diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte index 1a9d6ac..5f34ede 100644 --- a/src/lib/components/StatusBar.svelte +++ b/src/lib/components/StatusBar.svelte @@ -4,6 +4,7 @@ import { open } from "@tauri-apps/plugin-dialog"; import { openUrl } from "@tauri-apps/plugin-opener"; import { claudeStore } from "$lib/stores/claude"; + import { configStore, type HikariConfig } from "$lib/stores/config"; import type { ConnectionStatus } from "$lib/types/messages"; import { onMount } from "svelte"; @@ -15,6 +16,14 @@ let isConnecting = $state(false); let grantedToolsList: string[] = $state([]); let appVersion = $state(""); + let currentConfig: HikariConfig = $state({ + model: null, + api_key: null, + custom_instructions: null, + mcp_servers_json: null, + auto_granted_tools: [], + theme: "dark", + }); onMount(async () => { appVersion = await getVersion(); @@ -33,6 +42,10 @@ grantedToolsList = Array.from(tools); }); + configStore.config.subscribe((config) => { + currentConfig = config; + }); + async function handleBrowse() { try { const selected = await open({ @@ -54,11 +67,21 @@ const targetDir = selectedDirectory || "/home/naomi"; + // Combine session-granted tools with config auto-granted tools + const allAllowedTools = [ + ...new Set([...grantedToolsList, ...currentConfig.auto_granted_tools]), + ]; + try { - // Pass granted tools to Claude so they're pre-approved await invoke("start_claude", { - workingDir: targetDir, - allowedTools: grantedToolsList.length > 0 ? grantedToolsList : null, + options: { + working_dir: targetDir, + model: currentConfig.model || null, + api_key: currentConfig.api_key || null, + custom_instructions: currentConfig.custom_instructions || null, + mcp_servers_json: currentConfig.mcp_servers_json || null, + allowed_tools: allAllowedTools, + }, }); } catch (error) { console.error("Failed to start Claude:", error); @@ -140,6 +163,26 @@
+