diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3423569..b1d2edb 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -5,13 +5,20 @@ use reqwest; use uuid::Uuid; use nanoid; +use crate::helper::{ + ensure_dir, extract_string_array, get_project_path_from_claude_json, home_dir, + path_to_string, read_direct_servers, read_disabled_mcp_servers_from_claude_json, + read_json_file, read_json_file_mcp_servers, read_local_mcp_servers, read_mcpjson_servers, + read_project_mcp_servers, write_json_file, write_json_file_serialize, +}; + // Application configuration directory const APP_CONFIG_DIR: &str = ".ccconfig"; pub async fn initialize_app_config() -> Result<(), String> { println!("initialize_app_config called"); - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let app_config_path = home_dir.join(APP_CONFIG_DIR); println!( @@ -22,8 +29,7 @@ pub async fn initialize_app_config() -> Result<(), String> { // Create config directory if it doesn't exist if !app_config_path.exists() { println!("App config directory does not exist, creating..."); - std::fs::create_dir_all(&app_config_path) - .map_err(|e| format!("Failed to create app config directory: {}", e))?; + ensure_dir(&app_config_path, "app config directory")?; println!( "App config directory created: {}", app_config_path.display() @@ -113,7 +119,8 @@ pub struct McpServerState { pub in_disabled_array: bool, } -#[derive(serde::Serialize, serde::Deserialize, Debug)] +#[derive(serde::Serialize, serde::Deserialize, Debug, Default)] +#[serde(default)] pub struct StoresData { pub configs: Vec, pub distinct_id: Option, @@ -128,7 +135,7 @@ pub struct NotificationSettings { #[tauri::command] pub async fn read_config_file(config_type: String) -> Result { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let path = match config_type.as_str() { "user" => home_dir.join(".claude/settings.json"), @@ -143,43 +150,25 @@ pub async fn read_config_file(config_type: String) -> Result _ => return Err("Invalid configuration type".to_string()), }; - let path_str = path.to_string_lossy().to_string(); - - if path.exists() { - let content = - std::fs::read_to_string(&path).map_err(|e| format!("Failed to read file: {}", e))?; - - let json_content: Value = - serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?; - - Ok(ConfigFile { - path: path_str, - content: json_content, - exists: true, - }) - } else { - Ok(ConfigFile { - path: path_str, - content: Value::Object(serde_json::Map::new()), - exists: false, - }) - } + let path_str = path_to_string(&path); + let content = read_json_file(&path, "config file")?; + Ok(ConfigFile { + path: path_str, + content, + exists: path.exists(), + }) } #[tauri::command] pub async fn write_config_file(config_type: String, content: Value) -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let path = match config_type.as_str() { "user" => home_dir.join(".claude/settings.json"), _ => return Err("Cannot write to enterprise configuration files".to_string()), }; - let json_content = serde_json::to_string_pretty(&content) - .map_err(|e| format!("Failed to serialize JSON: {}", e))?; - - std::fs::write(&path, json_content).map_err(|e| format!("Failed to write file: {}", e))?; - + write_json_file(&path, &content, "config file")?; Ok(()) } @@ -234,18 +223,17 @@ pub async fn list_config_files() -> Result, String> { #[tauri::command] pub async fn check_app_config_exists() -> Result { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let app_config_path = home_dir.join(APP_CONFIG_DIR); Ok(app_config_path.exists()) } #[tauri::command] pub async fn create_app_config_dir() -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let app_config_path = home_dir.join(APP_CONFIG_DIR); - std::fs::create_dir_all(&app_config_path) - .map_err(|e| format!("Failed to create app config directory: {}", e))?; + ensure_dir(&app_config_path, "app config directory")?; Ok(()) } @@ -257,8 +245,7 @@ fn backup_claude_configs_internal( // Create backup directory let backup_dir = app_config_path.join("claude_backup"); - std::fs::create_dir_all(&backup_dir) - .map_err(|e| format!("Failed to create backup directory: {}", e))?; + ensure_dir(&backup_dir, "backup directory")?; // Copy all files from .claude directory to backup for entry in std::fs::read_dir(claude_dir) @@ -280,7 +267,7 @@ fn backup_claude_configs_internal( #[tauri::command] pub async fn backup_claude_configs() -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let claude_dir = home_dir.join(".claude"); let app_config_path = home_dir.join(APP_CONFIG_DIR); @@ -289,8 +276,7 @@ pub async fn backup_claude_configs() -> Result<(), String> { } // Ensure app config directory exists - std::fs::create_dir_all(&app_config_path) - .map_err(|e| format!("Failed to create app config directory: {}", e))?; + ensure_dir(&app_config_path, "app config directory")?; backup_claude_configs_internal(&app_config_path, &claude_dir) } @@ -299,19 +285,11 @@ pub async fn backup_claude_configs() -> Result<(), String> { #[tauri::command] pub async fn get_stores() -> Result, String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let app_config_path = home_dir.join(APP_CONFIG_DIR); let stores_file = app_config_path.join("stores.json"); - if !stores_file.exists() { - return Ok(vec![]); - } - - let content = std::fs::read_to_string(&stores_file) - .map_err(|e| format!("Failed to read stores file: {}", e))?; - - let mut stores_data: StoresData = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse stores file: {}", e))?; + let mut stores_data = read_stores_file(&stores_file)?; // Add default notification settings if they don't exist if stores_data.notification.is_none() { @@ -321,12 +299,7 @@ pub async fn get_stores() -> Result, String> { }); // Write back to stores file with notification settings added - let json_content = serde_json::to_string_pretty(&stores_data) - .map_err(|e| format!("Failed to serialize stores: {}", e))?; - - std::fs::write(&stores_file, json_content) - .map_err(|e| format!("Failed to write stores file: {}", e))?; - + write_json_file_serialize(&stores_file, &stores_data, "stores file")?; println!("Added default notification settings to existing stores.json"); } @@ -343,31 +316,21 @@ pub async fn create_config( title: String, settings: Value, ) -> Result { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let app_config_path = home_dir.join(APP_CONFIG_DIR); let stores_file = app_config_path.join("stores.json"); // Ensure app config directory exists - std::fs::create_dir_all(&app_config_path) - .map_err(|e| format!("Failed to create app config directory: {}", e))?; + ensure_dir(&app_config_path, "app config directory")?; // Read existing stores - let mut stores_data = if stores_file.exists() { - let content = std::fs::read_to_string(&stores_file) - .map_err(|e| format!("Failed to read stores file: {}", e))?; - - serde_json::from_str::(&content) - .map_err(|e| format!("Failed to parse stores file: {}", e))? - } else { - StoresData { - configs: vec![], - distinct_id: None, - notification: Some(NotificationSettings { - enable: true, - enabled_hooks: vec!["Notification".to_string()], - }), - } - }; + let mut stores_data = read_stores_file(&stores_file)?; + if stores_data.notification.is_none() { + stores_data.notification = Some(NotificationSettings { + enable: true, + enabled_hooks: vec!["Notification".to_string()], + }); + } // Determine if this should be the active store (true if no other stores exist) let should_be_active = stores_data.configs.is_empty(); @@ -377,11 +340,7 @@ pub async fn create_config( let claude_settings_path = home_dir.join(".claude/settings.json"); if claude_settings_path.exists() { // Read existing settings - let settings_content = std::fs::read_to_string(&claude_settings_path) - .map_err(|e| format!("Failed to read existing Claude settings: {}", e))?; - - let settings_json: Value = serde_json::from_str(&settings_content) - .map_err(|e| format!("Failed to parse existing Claude settings: {}", e))?; + let settings_json = read_json_file(&claude_settings_path, "Claude settings")?; // Create an Original Config store with existing settings let original_store = ConfigStore { @@ -407,19 +366,11 @@ pub async fn create_config( // Create .claude directory if it doesn't exist if let Some(parent) = user_settings_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create .claude directory: {}", e))?; + ensure_dir(parent, ".claude directory")?; } // Read existing settings if file exists, otherwise start with empty object - let mut existing_settings = if user_settings_path.exists() { - let content = std::fs::read_to_string(&user_settings_path) - .map_err(|e| format!("Failed to read existing settings: {}", e))?; - serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse existing settings: {}", e))? - } else { - serde_json::Value::Object(serde_json::Map::new()) - }; + let mut existing_settings = read_json_file(&user_settings_path, "settings")?; // Merge the new settings into existing settings (partial update) if let Some(settings_obj) = settings.as_object() { @@ -438,11 +389,7 @@ pub async fn create_config( } // Write the merged settings back to file - let json_content = serde_json::to_string_pretty(&existing_settings) - .map_err(|e| format!("Failed to serialize merged settings: {}", e))?; - - std::fs::write(&user_settings_path, json_content) - .map_err(|e| format!("Failed to write user settings: {}", e))?; + write_json_file(&user_settings_path, &existing_settings, "user settings")?; } // Create new store @@ -461,11 +408,7 @@ pub async fn create_config( stores_data.configs.push(new_store.clone()); // Write back to stores file - let json_content = serde_json::to_string_pretty(&stores_data) - .map_err(|e| format!("Failed to serialize stores: {}", e))?; - - std::fs::write(&stores_file, json_content) - .map_err(|e| format!("Failed to write stores file: {}", e))?; + write_json_file_serialize(&stores_file, &stores_data, "stores file")?; // Automatically unlock CC extension when creating new config if let Err(e) = unlock_cc_ext().await { @@ -477,7 +420,7 @@ pub async fn create_config( #[tauri::command] pub async fn delete_config(store_id: String) -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let app_config_path = home_dir.join(APP_CONFIG_DIR); let stores_file = app_config_path.join("stores.json"); @@ -486,11 +429,7 @@ pub async fn delete_config(store_id: String) -> Result<(), String> { } // Read existing stores - let content = std::fs::read_to_string(&stores_file) - .map_err(|e| format!("Failed to read stores file: {}", e))?; - - let mut stores_data: StoresData = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse stores file: {}", e))?; + let mut stores_data = read_stores_file(&stores_file)?; // Find and remove store by ID let original_len = stores_data.configs.len(); @@ -501,18 +440,14 @@ pub async fn delete_config(store_id: String) -> Result<(), String> { } // Write back to file - let json_content = serde_json::to_string_pretty(&stores_data) - .map_err(|e| format!("Failed to serialize stores: {}", e))?; - - std::fs::write(&stores_file, json_content) - .map_err(|e| format!("Failed to write stores file: {}", e))?; + write_json_file_serialize(&stores_file, &stores_data, "stores file")?; Ok(()) } #[tauri::command] pub async fn set_using_config(store_id: String) -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let app_config_path = home_dir.join(APP_CONFIG_DIR); let stores_file = app_config_path.join("stores.json"); @@ -521,11 +456,7 @@ pub async fn set_using_config(store_id: String) -> Result<(), String> { } // Read existing stores - let content = std::fs::read_to_string(&stores_file) - .map_err(|e| format!("Failed to read stores file: {}", e))?; - - let mut stores_data: StoresData = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse stores file: {}", e))?; + let mut stores_data = read_stores_file(&stores_file)?; // Find the store and check if it exists let store_found = stores_data.configs.iter().any(|store| store.id == store_id); @@ -550,19 +481,11 @@ pub async fn set_using_config(store_id: String) -> Result<(), String> { // Create .claude directory if it doesn't exist if let Some(parent) = user_settings_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create .claude directory: {}", e))?; + ensure_dir(parent, ".claude directory")?; } // Read existing settings if file exists, otherwise start with empty object - let mut existing_settings = if user_settings_path.exists() { - let content = std::fs::read_to_string(&user_settings_path) - .map_err(|e| format!("Failed to read existing settings: {}", e))?; - serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse existing settings: {}", e))? - } else { - serde_json::Value::Object(serde_json::Map::new()) - }; + let mut existing_settings = read_json_file(&user_settings_path, "settings")?; // Merge the new settings into existing settings (partial update) if let Some(settings_obj) = settings.as_object() { @@ -581,36 +504,24 @@ pub async fn set_using_config(store_id: String) -> Result<(), String> { } // Write the merged settings back to file - let json_content = serde_json::to_string_pretty(&existing_settings) - .map_err(|e| format!("Failed to serialize merged settings: {}", e))?; - - std::fs::write(&user_settings_path, json_content) - .map_err(|e| format!("Failed to write user settings: {}", e))?; + write_json_file(&user_settings_path, &existing_settings, "user settings")?; } // Write back to stores file - let json_content = serde_json::to_string_pretty(&stores_data) - .map_err(|e| format!("Failed to serialize stores: {}", e))?; - - std::fs::write(&stores_file, json_content) - .map_err(|e| format!("Failed to write stores file: {}", e))?; + write_json_file_serialize(&stores_file, &stores_data, "stores file")?; Ok(()) } #[tauri::command] pub async fn reset_to_original_config() -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let app_config_path = home_dir.join(APP_CONFIG_DIR); let stores_file = app_config_path.join("stores.json"); // Set all stores to not using if stores_file.exists() { - let content = std::fs::read_to_string(&stores_file) - .map_err(|e| format!("Failed to read stores file: {}", e))?; - - let mut stores_data: StoresData = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse stores file: {}", e))?; + let mut stores_data = read_stores_file(&stores_file)?; // Set all stores to not using for store in &mut stores_data.configs { @@ -618,11 +529,7 @@ pub async fn reset_to_original_config() -> Result<(), String> { } // Write back to stores file - let json_content = serde_json::to_string_pretty(&stores_data) - .map_err(|e| format!("Failed to serialize stores: {}", e))?; - - std::fs::write(&stores_file, json_content) - .map_err(|e| format!("Failed to write stores file: {}", e))?; + write_json_file_serialize(&stores_file, &stores_data, "stores file")?; } // Clear env field in settings.json @@ -630,19 +537,11 @@ pub async fn reset_to_original_config() -> Result<(), String> { // Create .claude directory if it doesn't exist if let Some(parent) = user_settings_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create .claude directory: {}", e))?; + ensure_dir(parent, ".claude directory")?; } // Read existing settings if file exists, otherwise start with empty object - let mut existing_settings = if user_settings_path.exists() { - let content = std::fs::read_to_string(&user_settings_path) - .map_err(|e| format!("Failed to read existing settings: {}", e))?; - serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse existing settings: {}", e))? - } else { - serde_json::Value::Object(serde_json::Map::new()) - }; + let mut existing_settings = read_json_file(&user_settings_path, "settings")?; // Set env to empty object if let Some(existing_obj) = existing_settings.as_object_mut() { @@ -650,11 +549,7 @@ pub async fn reset_to_original_config() -> Result<(), String> { } // Write the merged settings back to file - let json_content = serde_json::to_string_pretty(&existing_settings) - .map_err(|e| format!("Failed to serialize merged settings: {}", e))?; - - std::fs::write(&user_settings_path, json_content) - .map_err(|e| format!("Failed to write user settings: {}", e))?; + write_json_file(&user_settings_path, &existing_settings, "user settings")?; Ok(()) } @@ -680,7 +575,7 @@ pub async fn update_config( title: String, settings: Value, ) -> Result { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let app_config_path = home_dir.join(APP_CONFIG_DIR); let stores_file = app_config_path.join("stores.json"); @@ -689,11 +584,7 @@ pub async fn update_config( } // Read existing stores - let content = std::fs::read_to_string(&stores_file) - .map_err(|e| format!("Failed to read stores file: {}", e))?; - - let mut stores_data: StoresData = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse stores file: {}", e))?; + let mut stores_data = read_stores_file(&stores_file)?; // Find the store by ID let store_index = stores_data @@ -720,19 +611,11 @@ pub async fn update_config( // Create .claude directory if it doesn't exist if let Some(parent) = user_settings_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create .claude directory: {}", e))?; + ensure_dir(parent, ".claude directory")?; } // Read existing settings if file exists, otherwise start with empty object - let mut existing_settings = if user_settings_path.exists() { - let content = std::fs::read_to_string(&user_settings_path) - .map_err(|e| format!("Failed to read existing settings: {}", e))?; - serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse existing settings: {}", e))? - } else { - serde_json::Value::Object(serde_json::Map::new()) - }; + let mut existing_settings = read_json_file(&user_settings_path, "settings")?; // Merge the new settings into existing settings (partial update) if let Some(settings_obj) = settings.as_object() { @@ -751,19 +634,11 @@ pub async fn update_config( } // Write the merged settings back to file - let json_content = serde_json::to_string_pretty(&existing_settings) - .map_err(|e| format!("Failed to serialize merged settings: {}", e))?; - - std::fs::write(&user_settings_path, json_content) - .map_err(|e| format!("Failed to write user settings: {}", e))?; + write_json_file(&user_settings_path, &existing_settings, "user settings")?; } // Write back to stores file - let json_content = serde_json::to_string_pretty(&stores_data) - .map_err(|e| format!("Failed to serialize stores: {}", e))?; - - std::fs::write(&stores_file, json_content) - .map_err(|e| format!("Failed to write stores file: {}", e))?; + write_json_file_serialize(&stores_file, &stores_data, "stores file")?; // Automatically unlock CC extension when updating config if let Err(e) = unlock_cc_ext().await { @@ -775,13 +650,12 @@ pub async fn update_config( #[tauri::command] pub async fn open_config_path() -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let app_config_path = home_dir.join(APP_CONFIG_DIR); // Ensure the directory exists if !app_config_path.exists() { - std::fs::create_dir_all(&app_config_path) - .map_err(|e| format!("Failed to create config directory: {}", e))?; + ensure_dir(&app_config_path, "config directory")?; } // Open the directory in the system's file manager @@ -814,57 +688,86 @@ pub async fn open_config_path() -> Result<(), String> { // MCP Server management functions -// Helper: Read MCPJSON servers from ~/.mcp.json -fn read_mcpjson_servers(home_dir: &std::path::Path) -> Result, String> { - let mcp_json_path = home_dir.join(".mcp.json"); - if !mcp_json_path.exists() { - return Ok(serde_json::Map::new()); - } - - let content = std::fs::read_to_string(&mcp_json_path) - .map_err(|e| format!("Failed to read .mcp.json: {}", e))?; - let json_value: Value = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse .mcp.json: {}", e))?; - - Ok(json_value.get("mcpServers") - .and_then(|s| s.as_object()) - .cloned() - .unwrap_or_else(serde_json::Map::new)) +// Helper: Read and parse stores file (returns default when file missing) +fn read_stores_file(path: &std::path::Path) -> Result { + let value = read_json_file(path, "stores file")?; + serde_json::from_value(value).map_err(|e| format!("Failed to parse stores file: {}", e)) } -// Helper: Read Direct servers from ~/.claude.json -fn read_direct_servers(home_dir: &std::path::Path) -> Result, String> { - let claude_json_path = home_dir.join(".claude.json"); - if !claude_json_path.exists() { - return Ok(serde_json::Map::new()); +// Helper: Write serializable value as JSON file +// Helper: Get settings file path based on cwd and preference +fn get_settings_path(cwd: Option<&str>, prefer_local: bool) -> Result { + let home_dir = home_dir()?; + + if let Some(cwd_str) = cwd { + if let Ok(Some(project_path)) = get_project_path_from_claude_json(cwd_str) { + if prefer_local { + // Write to ./.claude/settings.local.json (highest priority, gitignored) + return Ok(project_path.join(".claude/settings.local.json")); + } else { + // Read from project settings (check local first, then project) + let local_settings_path = project_path.join(".claude/settings.local.json"); + if local_settings_path.exists() { + return Ok(local_settings_path); + } + let project_settings_path = project_path.join(".claude/settings.json"); + if project_settings_path.exists() { + return Ok(project_settings_path); + } + } + } } - let content = std::fs::read_to_string(&claude_json_path) - .map_err(|e| format!("Failed to read .claude.json: {}", e))?; - let json_value: Value = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse .claude.json: {}", e))?; - - Ok(json_value.get("mcpServers") - .and_then(|s| s.as_object()) - .cloned() - .unwrap_or_else(serde_json::Map::new)) + // Fallback to user-global settings + Ok(home_dir.join(".claude/settings.json")) +} + +// Helper: Create McpServer struct +fn create_mcp_server( + config: Value, + source_type: &str, + scope: &str, + defined_in: String, + controllable: bool, +) -> McpServer { + McpServer { + config, + source_type: source_type.to_string(), + scope: scope.to_string(), + defined_in, + controllable, + } +} + +// Helper: Check if plugin install should be included based on scope and cwd +fn should_include_install(install: &PluginInstallInfo, cwd: Option<&str>) -> bool { + match cwd { + Some(cwd_str) if !cwd_str.is_empty() => { + install.scope == "user" + || (install.scope == "local" && install.project_path.as_deref() == Some(cwd_str)) + } + _ => true, // Include all when cwd is None or empty (Global view) + } } #[tauri::command] pub async fn get_global_mcp_servers() -> Result, String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let mut result = std::collections::HashMap::new(); // 1. Read from ~/.mcp.json (MCPJSON servers - user scope) if let Ok(mcpjson_servers) = read_mcpjson_servers(&home_dir) { for (name, config) in mcpjson_servers { - result.insert(name.clone(), McpServer { - config, - source_type: "mcpjson".to_string(), - scope: "user".to_string(), - defined_in: home_dir.join(".mcp.json").to_string_lossy().to_string(), - controllable: true, - }); + result.insert( + name.clone(), + create_mcp_server( + config, + "mcpjson", + "user", + path_to_string(&home_dir.join(".mcp.json")), + true, + ), + ); } } @@ -872,12 +775,14 @@ pub async fn get_global_mcp_servers() -> Result Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let mcp_json_path = home_dir.join(".mcp.json"); // Read existing .mcp.json or create new structure - let mut json_value = if mcp_json_path.exists() { - let content = std::fs::read_to_string(&mcp_json_path) - .map_err(|e| format!("Failed to read .mcp.json: {}", e))?; - serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse .mcp.json: {}", e))? - } else { - Value::Object(serde_json::Map::new()) - }; + let mut json_value = read_json_file(&mcp_json_path, ".mcp.json")?; // Update mcpServers object (same structure as .claude.json) let mcp_servers = json_value @@ -922,18 +820,14 @@ pub async fn update_global_mcp_server( mcp_servers.insert(server_name, server_config); // Write back to file - let json_content = serde_json::to_string_pretty(&json_value) - .map_err(|e| format!("Failed to serialize JSON: {}", e))?; - - std::fs::write(&mcp_json_path, json_content) - .map_err(|e| format!("Failed to write .mcp.json: {}", e))?; + write_json_file(&mcp_json_path, &json_value, ".mcp.json")?; Ok(()) } #[tauri::command] pub async fn delete_global_mcp_server(server_name: String) -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let mcp_json_path = home_dir.join(".mcp.json"); if !mcp_json_path.exists() { @@ -941,23 +835,15 @@ pub async fn delete_global_mcp_server(server_name: String) -> Result<(), String> } // Read existing .mcp.json - let content = std::fs::read_to_string(&mcp_json_path) - .map_err(|e| format!("Failed to read .mcp.json: {}", e))?; - - let mut json_value: Value = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse .mcp.json: {}", e))?; + let mut json_value = read_json_file(&mcp_json_path, ".mcp.json")?; // Check if mcpServers exists let mcp_servers = json_value .as_object_mut() .unwrap() .get_mut("mcpServers") - .and_then(|servers| servers.as_object_mut()); - - let mcp_servers = match mcp_servers { - Some(servers) => servers, - None => return Err("No mcpServers found in .mcp.json".to_string()), - }; + .and_then(|servers| servers.as_object_mut()) + .ok_or("No mcpServers found in .mcp.json")?; // Check if the server exists if !mcp_servers.contains_key(&server_name) { @@ -973,11 +859,7 @@ pub async fn delete_global_mcp_server(server_name: String) -> Result<(), String> } // Write back to file - let json_content = serde_json::to_string_pretty(&json_value) - .map_err(|e| format!("Failed to serialize JSON: {}", e))?; - - std::fs::write(&mcp_json_path, json_content) - .map_err(|e| format!("Failed to write .mcp.json: {}", e))?; + write_json_file(&mcp_json_path, &json_value, ".mcp.json")?; // Also remove from settings.json enabled/disabled arrays remove_mcp_from_settings(&server_name).await?; @@ -987,19 +869,14 @@ pub async fn delete_global_mcp_server(server_name: String) -> Result<(), String> // Helper function to remove MCP server from settings arrays async fn remove_mcp_from_settings(server_name: &str) -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let settings_path = home_dir.join(".claude/settings.json"); if !settings_path.exists() { return Ok(()); // Nothing to remove if settings doesn't exist } - let content = std::fs::read_to_string(&settings_path) - .map_err(|e| format!("Failed to read settings.json: {}", e))?; - - let mut settings: Value = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse settings.json: {}", e))?; - + let mut settings = read_json_file(&settings_path, "settings.json")?; let settings_obj = settings.as_object_mut() .ok_or("Settings is not an object")?; @@ -1017,11 +894,7 @@ async fn remove_mcp_from_settings(server_name: &str) -> Result<(), String> { } } - let json_content = serde_json::to_string_pretty(&settings) - .map_err(|e| format!("Failed to serialize settings: {}", e))?; - - std::fs::write(&settings_path, json_content) - .map_err(|e| format!("Failed to write settings.json: {}", e))?; + write_json_file(&settings_path, &settings, "settings.json")?; Ok(()) } @@ -1032,64 +905,69 @@ pub struct McpEnabledState { pub enabled_mcp_json_servers: Vec, #[serde(rename = "disabledMcpjsonServers")] pub disabled_mcp_json_servers: Vec, + #[serde(rename = "disabledMcpServers")] + pub disabled_mcp_servers: Vec, // For Direct servers } -#[tauri::command] -pub async fn get_mcp_enabled_state() -> Result { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; - let settings_path = home_dir.join(".claude/settings.json"); - +// Helper: Read settings from a specific file path +fn read_settings_from_file(settings_path: &std::path::Path) -> Result { if !settings_path.exists() { return Ok(McpEnabledState { enabled_mcp_json_servers: vec![], disabled_mcp_json_servers: vec![], + disabled_mcp_servers: vec![], }); } - let content = std::fs::read_to_string(&settings_path) - .map_err(|e| format!("Failed to read settings.json: {}", e))?; - - let settings: Value = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse settings.json: {}", e))?; - - let enabled = settings.get("enabledMcpjsonServers") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) - .unwrap_or_else(Vec::new); - - let disabled = settings.get("disabledMcpjsonServers") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()) - .unwrap_or_else(Vec::new); + let settings = read_json_file(settings_path, "settings file")?; Ok(McpEnabledState { - enabled_mcp_json_servers: enabled, - disabled_mcp_json_servers: disabled, + enabled_mcp_json_servers: extract_string_array(&settings, "enabledMcpjsonServers"), + disabled_mcp_json_servers: extract_string_array(&settings, "disabledMcpjsonServers"), + disabled_mcp_servers: extract_string_array(&settings, "disabledMcpServers"), }) } +// Helper: Merge disabled MCP servers from .claude.json into state +fn merge_disabled_mcp_servers(mut state: McpEnabledState, cwd: Option<&str>) -> Result { + state.disabled_mcp_servers = read_disabled_mcp_servers_from_claude_json(cwd)?; + Ok(state) +} + #[tauri::command] -pub async fn toggle_mcp_server_state(server_name: String, enabled: bool) -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; - let settings_path = home_dir.join(".claude/settings.json"); +pub async fn get_mcp_enabled_state(cwd: Option) -> Result { + let settings_path = get_settings_path(cwd.as_deref(), false)?; + let state = read_settings_from_file(&settings_path)?; + merge_disabled_mcp_servers(state, cwd.as_deref()) +} + +#[tauri::command] +pub async fn toggle_mcp_server_state(server_name: String, enabled: bool, cwd: Option) -> Result<(), String> { + // Determine target settings file based on cwd (prefer local for writing) + let settings_path = get_settings_path(cwd.as_deref(), true)?; + + // Log the action + let project_info = if let Some(ref cwd_str) = cwd { + format!("project: {}", cwd_str) + } else { + "global".to_string() + }; + let action = if enabled { "enabled" } else { "disabled" }; + println!("🔧 MCP server {} {} - file: {}, {}", + action, + server_name, + settings_path.display(), + project_info + ); // Ensure settings directory exists let settings_dir = settings_path.parent().ok_or("Failed to get settings directory")?; if !settings_dir.exists() { - std::fs::create_dir_all(settings_dir) - .map_err(|e| format!("Failed to create settings directory: {}", e))?; + ensure_dir(settings_dir, "settings directory")?; } // Read existing settings or create new - let mut settings = if settings_path.exists() { - let content = std::fs::read_to_string(&settings_path) - .map_err(|e| format!("Failed to read settings.json: {}", e))?; - serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse settings.json: {}", e))? - } else { - Value::Object(serde_json::Map::new()) - }; - + let mut settings = read_json_file(&settings_path, "settings file")?; let settings_obj = settings.as_object_mut() .ok_or("Settings is not an object")?; @@ -1106,7 +984,10 @@ pub async fn toggle_mcp_server_state(server_name: String, enabled: bool) -> Resu .get_mut("enabledMcpjsonServers") .and_then(|v| v.as_array_mut()) { - enabled_arr.retain(|v| v.as_str() != Some(&server_name)); + enabled_arr.retain(|v: &Value| v.as_str() != Some(&server_name)); + if enabled { + enabled_arr.push(Value::String(server_name.clone())); + } } // Remove from disabled array @@ -1114,54 +995,304 @@ pub async fn toggle_mcp_server_state(server_name: String, enabled: bool) -> Resu .get_mut("disabledMcpjsonServers") .and_then(|v| v.as_array_mut()) { - disabled_arr.retain(|v| v.as_str() != Some(&server_name)); - } - - // Add to appropriate array - if enabled { - if let Some(enabled_arr) = settings_obj - .get_mut("enabledMcpjsonServers") - .and_then(|v| v.as_array_mut()) - { - enabled_arr.push(Value::String(server_name)); - } - } else { - if let Some(disabled_arr) = settings_obj - .get_mut("disabledMcpjsonServers") - .and_then(|v| v.as_array_mut()) - { + disabled_arr.retain(|v: &Value| v.as_str() != Some(&server_name)); + if !enabled { disabled_arr.push(Value::String(server_name)); } } // Write back to file - let json_content = serde_json::to_string_pretty(&settings) - .map_err(|e| format!("Failed to serialize settings: {}", e))?; - - std::fs::write(&settings_path, json_content) - .map_err(|e| format!("Failed to write settings.json: {}", e))?; + write_json_file(&settings_path, &settings, "settings file")?; Ok(()) } #[tauri::command] -pub async fn get_mcp_servers_with_state() -> Result, String> { - let servers = get_global_mcp_servers().await?; - let state = get_mcp_enabled_state().await?; +pub async fn toggle_direct_mcp_server( + server_name: String, + enabled: bool, + cwd: Option +) -> Result<(), String> { + let home_dir = home_dir()?; + let claude_json_path = home_dir.join(".claude.json"); + + // Log the action + let project_info = if let Some(ref cwd_str) = cwd { + format!("project: {}", cwd_str) + } else { + "global".to_string() + }; + let action = if enabled { "enabled" } else { "disabled" }; + println!("🔧 MCP server {} {} - file: {}, {}", + action, + server_name, + claude_json_path.display(), + project_info + ); + + let mut json_value = read_json_file(&claude_json_path, ".claude.json")?; + let json_obj = json_value.as_object_mut().ok_or(".claude.json is not an object")?; + + // Determine target object: project-specific or root level + let target_obj = if let Some(ref cwd_str) = cwd { + // Write to .projects[cwd].disabledMcpServers + let projects = json_obj + .entry("projects".to_string()) + .or_insert_with(|| Value::Object(serde_json::Map::new())) + .as_object_mut() + .ok_or("projects is not an object")?; + + let project = projects + .entry(cwd_str.clone()) + .or_insert_with(|| Value::Object(serde_json::Map::new())) + .as_object_mut() + .ok_or("project entry is not an object")?; + + project + } else { + // Write to root level + json_obj + }; + + // Update disabledMcpServers array + let disabled_arr = target_obj + .entry("disabledMcpServers".to_string()) + .or_insert_with(|| Value::Array(vec![])) + .as_array_mut() + .ok_or("disabledMcpServers is not an array")?; + + disabled_arr.retain(|v| v.as_str() != Some(&server_name)); + + if !enabled { + disabled_arr.push(Value::String(server_name)); + } + + // Write back + write_json_file(&claude_json_path, &json_value, ".claude.json")?; + + Ok(()) +} + +// Helper: Read MCP servers from enabled plugins. +// When cwd is None (Global): include all installs (user + every project's local). +// When cwd is Some(path): include only user-scope installs + local-scope installs for that project. +fn read_plugin_mcp_servers(cwd: Option<&str>) -> Result, String, String)>, String> { + let home_dir = home_dir()?; + let plugins_file_path = home_dir.join(".claude/plugins/installed_plugins.json"); + + if !plugins_file_path.exists() { + return Ok(vec![]); + } + + let content = std::fs::read_to_string(&plugins_file_path) + .map_err(|e| format!("Failed to read installed_plugins.json: {}", e))?; + + let installed: InstalledPluginsFile = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse installed_plugins.json: {}", e))?; + + let mut enabled_cache: std::collections::HashMap> = + std::collections::HashMap::new(); + let mut result = Vec::new(); + + for (plugin_name, installs) in installed.plugins { + for install in installs { + // Filter by scope when a project is selected + if !should_include_install(&install, cwd) { + continue; + } + let enabled = if let Some(path) = + enabled_plugins_settings_path(&home_dir, &install.scope, install.project_path.as_ref()) + { + let map = enabled_cache + .entry(path.clone()) + .or_insert_with(|| read_enabled_plugins(&path).unwrap_or_default()); + map.get(&plugin_name).copied().unwrap_or(true) + } else { + true + }; + if !enabled { + continue; + } + + let packages = detect_packages(&install.install_path)?; + if !packages.has_mcp { + continue; + } + + // Read .mcp.json from plugin directory + let plugin_mcp_path = std::path::Path::new(&install.install_path).join(".mcp.json"); + if !plugin_mcp_path.exists() { + continue; + } + + let json_value = read_json_file(&plugin_mcp_path, "plugin .mcp.json")?; + + // Try standard format: mcpServers as object (server name -> config) + if let Some(mcp_servers) = json_value.get("mcpServers").and_then(|s| s.as_object()) { + for (server_name, server_config) in mcp_servers { + if let Some(obj) = server_config.as_object() { + result.push(( + server_name.clone(), + obj.clone(), + plugin_name.clone(), + install.scope.clone(), + )); + } + } + continue; + } + + // Try alternative format: mcpServers as array of configs + if let Some(arr) = json_value.get("mcpServers").and_then(|s| s.as_array()) { + for (i, item) in arr.iter().enumerate() { + if let Some(obj) = item.as_object() { + let name = obj + .get("name") + .and_then(|v| v.as_str()) + .map(String::from) + .unwrap_or_else(|| format!("{}-{}", plugin_name, i)); + result.push(( + name, + obj.clone(), + plugin_name.clone(), + install.scope.clone(), + )); + } + } + continue; + } + + // Try alternative format: top-level object is the single server config (no mcpServers wrapper) + if json_value.get("mcpServers").is_none() { + if let Some(obj) = json_value.as_object() { + result.push(( + plugin_name.clone(), + obj.clone(), + plugin_name.clone(), + install.scope.clone(), + )); + } + } + } + } + + Ok(result) +} + +#[tauri::command] +pub async fn get_mcp_servers_with_state(cwd: Option) -> Result, String> { + let mut servers_map: std::collections::HashMap = std::collections::HashMap::new(); + let home_dir = home_dir()?; + + // Priority 1 (lowest): User-global from ~/.mcp.json and ~/.claude.json + if let Ok(user_mcpjson) = read_mcpjson_servers(&home_dir) { + for (name, config) in user_mcpjson { + servers_map.insert( + name.clone(), + create_mcp_server( + config, + "mcpjson", + "user", + path_to_string(&home_dir.join(".mcp.json")), + true, + ), + ); + } + } + + if let Ok(user_direct) = read_direct_servers(&home_dir) { + for (name, config) in user_direct { + servers_map.entry(name.clone()).or_insert_with(|| { + create_mcp_server( + config, + "direct", + "user", + path_to_string(&home_dir.join(".claude.json")), + false, + ) + }); + } + } + + // Priority 1.5: Plugin MCP servers (between user-global and project) + if let Ok(plugin_servers) = read_plugin_mcp_servers(cwd.as_deref()) { + for (name, config, plugin_name, plugin_scope) in plugin_servers { + servers_map.entry(name.clone()).or_insert_with(|| { + create_mcp_server( + Value::Object(config), + "plugin", + &format!("plugin-{}", plugin_scope), + format!("Plugin: {} ({})", plugin_name, plugin_scope), + true, + ) + }); + } + } + + // Priority 2: Project scope (if cwd provided) + if let Some(ref cwd_str) = cwd { + if let Ok(project_servers) = read_project_mcp_servers(cwd_str) { + for (name, config) in project_servers { + servers_map.insert( + name.clone(), + create_mcp_server( + config, + "direct", + "project", + format!("~/.claude.json .projects[{}]", cwd_str), + false, + ), + ); + } + } + } + + // Priority 3 (highest): Local scope (if cwd provided and project path exists) + if let Some(ref cwd_str) = cwd { + if let Ok(Some(project_path)) = get_project_path_from_claude_json(cwd_str) { + if let Ok(local_servers) = read_local_mcp_servers(&project_path) { + for (name, config) in local_servers { + servers_map.insert( + name.clone(), + create_mcp_server( + config, + "mcpjson", + "local", + path_to_string(&project_path.join(".mcp.json")), + true, + ), + ); + } + } + } + } + + // Get enabled/disabled state and compute final state + let state = get_mcp_enabled_state(cwd.clone()).await?; let mut result = Vec::new(); - for (name, server) in servers { + for (name, server) in servers_map { let in_enabled = state.enabled_mcp_json_servers.contains(&name); let in_disabled = state.disabled_mcp_json_servers.contains(&name); - // Compute state based on three-state logic - let computed_state = if in_disabled && !in_enabled { - "disabled" // Completely disabled - } else if in_enabled && in_disabled { - "runtime-disabled" // Configured but temporarily disabled + // Compute state based on source type and arrays + let computed_state = if server.source_type == "direct" { + // For Direct servers, check disabledMcpServers + if state.disabled_mcp_servers.contains(&name) { + "disabled" + } else { + "enabled" + } } else { - "enabled" // Default or explicitly enabled + // For MCPJSON servers, use three-state logic + if in_disabled && !in_enabled { + "disabled" // Completely disabled + } else if in_enabled && in_disabled { + "runtime-disabled" // Configured but temporarily disabled + } else { + "enabled" // Default or explicitly enabled + } }; result.push(McpServerState { @@ -1177,6 +1308,9 @@ pub async fn get_mcp_servers_with_state() -> Result, String> }); } + // Sort by name for consistent ordering + result.sort_by(|a, b| a.name.cmp(&b.name)); + Ok(result) } @@ -1245,13 +1379,12 @@ pub async fn rebuild_tray_menu_command(app: tauri::AppHandle) -> Result<(), Stri #[tauri::command] pub async fn unlock_cc_ext() -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let claude_config_path = home_dir.join(".claude/config.json"); // Ensure .claude directory exists if let Some(parent) = claude_config_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create .claude directory: {}", e))?; + ensure_dir(parent, ".claude directory")?; } if claude_config_path.exists() { @@ -1315,7 +1448,7 @@ pub struct ProjectUsageRecord { #[tauri::command] pub async fn read_project_usage_files() -> Result, String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let projects_dir = home_dir.join(".claude/projects"); println!("🔍 Looking for projects directory: {}", projects_dir.display()); @@ -1453,10 +1586,10 @@ pub struct MemoryFile { #[tauri::command] pub async fn read_claude_memory() -> Result { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let claude_md_path = home_dir.join(".claude/CLAUDE.md"); - let path_str = claude_md_path.to_string_lossy().to_string(); + let path_str = path_to_string(&claude_md_path); if claude_md_path.exists() { let content = std::fs::read_to_string(&claude_md_path) @@ -1478,13 +1611,12 @@ pub async fn read_claude_memory() -> Result { #[tauri::command] pub async fn write_claude_memory(content: String) -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let claude_md_path = home_dir.join(".claude/CLAUDE.md"); // Ensure .claude directory exists if let Some(parent) = claude_md_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create .claude directory: {}", e))?; + ensure_dir(parent, ".claude directory")?; } std::fs::write(&claude_md_path, content) @@ -1559,21 +1691,16 @@ pub async fn install_and_restart(app: tauri::AppHandle) -> Result<(), String> { // Get or create distinct_id from stores.json async fn get_or_create_distinct_id() -> Result { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let app_config_path = home_dir.join(APP_CONFIG_DIR); let stores_file = app_config_path.join("stores.json"); // Ensure app config directory exists - std::fs::create_dir_all(&app_config_path) - .map_err(|e| format!("Failed to create app config directory: {}", e))?; + ensure_dir(&app_config_path, "app config directory")?; // Read existing stores.json or create new one let mut stores_data = if stores_file.exists() { - let content = std::fs::read_to_string(&stores_file) - .map_err(|e| format!("Failed to read stores file: {}", e))?; - - serde_json::from_str::(&content) - .map_err(|e| format!("Failed to parse stores file: {}", e))? + read_stores_file(&stores_file)? } else { StoresData { configs: vec![], @@ -1594,11 +1721,7 @@ async fn get_or_create_distinct_id() -> Result { stores_data.distinct_id = Some(new_id.clone()); // Write back to stores.json - let json_content = serde_json::to_string_pretty(&stores_data) - .map_err(|e| format!("Failed to serialize stores data: {}", e))?; - - std::fs::write(&stores_file, json_content) - .map_err(|e| format!("Failed to write stores file: {}", e))?; + write_json_file_serialize(&stores_file, &stores_data, "stores file")?; println!("Created new distinct_id: {}", new_id); Ok(new_id) @@ -1694,7 +1817,7 @@ pub struct ProjectConfig { #[tauri::command] pub async fn read_claude_projects() -> Result, String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let claude_json_path = home_dir.join(".claude.json"); if !claude_json_path.exists() { @@ -1733,10 +1856,10 @@ pub struct ClaudeConfigFile { #[tauri::command] pub async fn read_claude_config_file() -> Result { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let claude_json_path = home_dir.join(".claude.json"); - let path_str = claude_json_path.to_string_lossy().to_string(); + let path_str = path_to_string(&claude_json_path); if claude_json_path.exists() { let content = std::fs::read_to_string(&claude_json_path) @@ -1761,7 +1884,7 @@ pub async fn read_claude_config_file() -> Result { #[tauri::command] pub async fn write_claude_config_file(content: Value) -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let claude_json_path = home_dir.join(".claude.json"); let json_content = serde_json::to_string_pretty(&content) @@ -1944,7 +2067,7 @@ fn update_or_add_hooks(hooks_obj: &mut serde_json::Map Result, String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let app_config_path = home_dir.join(APP_CONFIG_DIR); let stores_file = app_config_path.join("stores.json"); @@ -1952,18 +2075,14 @@ pub async fn get_notification_settings() -> Result, return Ok(None); } - let content = std::fs::read_to_string(&stores_file) - .map_err(|e| format!("Failed to read stores file: {}", e))?; - - let stores_data: StoresData = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse stores file: {}", e))?; + let stores_data = read_stores_file(&stores_file)?; Ok(stores_data.notification) } #[tauri::command] pub async fn update_claude_code_hook() -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let settings_path = home_dir.join(".claude/settings.json"); if !settings_path.exists() { @@ -1972,11 +2091,7 @@ pub async fn update_claude_code_hook() -> Result<(), String> { } // Read existing settings - let content = std::fs::read_to_string(&settings_path) - .map_err(|e| format!("Failed to read settings.json: {}", e))?; - - let mut settings: serde_json::Value = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse settings.json: {}", e))?; + let mut settings = read_json_file(&settings_path, "settings.json")?; // Ensure hooks object exists let hooks_obj = settings @@ -1993,17 +2108,12 @@ pub async fn update_claude_code_hook() -> Result<(), String> { if hook_updated { // Write back to settings file - let json_content = serde_json::to_string_pretty(&settings) - .map_err(|e| format!("Failed to serialize settings: {}", e))?; - // Create .claude directory if it doesn't exist if let Some(parent) = settings_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create .claude directory: {}", e))?; + ensure_dir(parent, ".claude directory")?; } - std::fs::write(&settings_path, json_content) - .map_err(|e| format!("Failed to write settings.json: {}", e))?; + write_json_file(&settings_path, &settings, "settings.json")?; println!("✅ Claude Code hooks updated successfully"); } else { @@ -2015,18 +2125,11 @@ pub async fn update_claude_code_hook() -> Result<(), String> { #[tauri::command] pub async fn add_claude_code_hook() -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let settings_path = home_dir.join(".claude/settings.json"); // Read existing settings or create new structure - let mut settings = if settings_path.exists() { - let content = std::fs::read_to_string(&settings_path) - .map_err(|e| format!("Failed to read settings.json: {}", e))?; - serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse settings.json: {}", e))? - } else { - serde_json::Value::Object(serde_json::Map::new()) - }; + let mut settings = read_json_file(&settings_path, "settings.json")?; // Ensure hooks object exists let hooks_obj = settings @@ -2042,25 +2145,19 @@ pub async fn add_claude_code_hook() -> Result<(), String> { update_or_add_hooks(hooks_obj, &events)?; // Write back to settings file - let json_content = serde_json::to_string_pretty(&settings) - .map_err(|e| format!("Failed to serialize settings: {}", e))?; - // Create .claude directory if it doesn't exist if let Some(parent) = settings_path.parent() { - std::fs::create_dir_all(parent) - .map_err(|e| format!("Failed to create .claude directory: {}", e))?; + ensure_dir(parent, ".claude directory")?; } - std::fs::write(&settings_path, json_content) - .map_err(|e| format!("Failed to write settings.json: {}", e))?; - + write_json_file(&settings_path, &settings, "settings.json")?; println!("✅ Claude Code hooks added successfully"); Ok(()) } #[tauri::command] pub async fn remove_claude_code_hook() -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let settings_path = home_dir.join(".claude/settings.json"); if !settings_path.exists() { @@ -2068,11 +2165,7 @@ pub async fn remove_claude_code_hook() -> Result<(), String> { } // Read existing settings - let content = std::fs::read_to_string(&settings_path) - .map_err(|e| format!("Failed to read settings.json: {}", e))?; - - let mut settings: serde_json::Value = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse settings.json: {}", e))?; + let mut settings = read_json_file(&settings_path, "settings.json")?; // Check if hooks object exists if let Some(hooks_obj) = settings.get_mut("hooks").and_then(|h| h.as_object_mut()) { @@ -2117,19 +2210,14 @@ pub async fn remove_claude_code_hook() -> Result<(), String> { } // Write back to settings file - let json_content = serde_json::to_string_pretty(&settings) - .map_err(|e| format!("Failed to serialize settings: {}", e))?; - - std::fs::write(&settings_path, json_content) - .map_err(|e| format!("Failed to write settings.json: {}", e))?; - + write_json_file(&settings_path, &settings, "settings.json")?; println!("✅ Claude Code hooks removed successfully"); Ok(()) } #[tauri::command] pub async fn update_notification_settings(settings: NotificationSettings) -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let app_config_path = home_dir.join(APP_CONFIG_DIR); let stores_file = app_config_path.join("stores.json"); @@ -2142,35 +2230,22 @@ pub async fn update_notification_settings(settings: NotificationSettings) -> Res }; // Ensure app config directory exists - std::fs::create_dir_all(&app_config_path) - .map_err(|e| format!("Failed to create app config directory: {}", e))?; + ensure_dir(&app_config_path, "app config directory")?; - let json_content = serde_json::to_string_pretty(&stores_data) - .map_err(|e| format!("Failed to serialize stores: {}", e))?; - - std::fs::write(&stores_file, json_content) - .map_err(|e| format!("Failed to write stores file: {}", e))?; + write_json_file_serialize(&stores_file, &stores_data, "stores file")?; println!("Created stores.json with notification settings"); return Ok(()); } // Read existing stores - let content = std::fs::read_to_string(&stores_file) - .map_err(|e| format!("Failed to read stores file: {}", e))?; - - let mut stores_data: StoresData = serde_json::from_str(&content) - .map_err(|e| format!("Failed to parse stores file: {}", e))?; + let mut stores_data = read_stores_file(&stores_file)?; // Update notification settings stores_data.notification = Some(settings); // Write back to stores file - let json_content = serde_json::to_string_pretty(&stores_data) - .map_err(|e| format!("Failed to serialize stores: {}", e))?; - - std::fs::write(&stores_file, json_content) - .map_err(|e| format!("Failed to write stores file: {}", e))?; + write_json_file_serialize(&stores_file, &stores_data, "stores file")?; println!("✅ Notification settings updated successfully"); Ok(()) @@ -2188,7 +2263,7 @@ pub struct CommandFile { #[tauri::command] pub async fn read_claude_commands() -> Result, String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let commands_dir = home_dir.join(".claude/commands"); if !commands_dir.exists() { @@ -2248,13 +2323,12 @@ pub async fn read_claude_commands() -> Result, String> { #[tauri::command] pub async fn write_claude_command(command_name: String, content: String) -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let commands_dir = home_dir.join(".claude/commands"); let command_file_path = commands_dir.join(format!("{}.md", command_name)); // Ensure .claude/commands directory exists - std::fs::create_dir_all(&commands_dir) - .map_err(|e| format!("Failed to create .claude/commands directory: {}", e))?; + ensure_dir(&commands_dir, ".claude/commands directory")?; std::fs::write(&command_file_path, content) .map_err(|e| format!("Failed to write command file: {}", e))?; @@ -2264,7 +2338,7 @@ pub async fn write_claude_command(command_name: String, content: String) -> Resu #[tauri::command] pub async fn delete_claude_command(command_name: String) -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let commands_dir = home_dir.join(".claude/commands"); let command_file_path = commands_dir.join(format!("{}.md", command_name)); @@ -2278,7 +2352,7 @@ pub async fn delete_claude_command(command_name: String) -> Result<(), String> { #[tauri::command] pub async fn toggle_claude_command(command_name: String, disabled: bool) -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let commands_dir = home_dir.join(".claude/commands"); let (source_path, target_path) = if disabled { @@ -2317,9 +2391,22 @@ pub struct AgentFile { pub exists: bool, } +#[derive(serde::Serialize)] +pub struct PluginAgentFile { + pub name: String, + pub content: String, + pub exists: bool, + #[serde(rename = "pluginName")] + pub plugin_name: String, + #[serde(rename = "pluginScope")] + pub plugin_scope: String, + #[serde(rename = "sourcePath")] + pub source_path: String, +} + #[tauri::command] pub async fn read_claude_agents() -> Result, String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let agents_dir = home_dir.join(".claude/agents"); if !agents_dir.exists() { @@ -2361,13 +2448,12 @@ pub async fn read_claude_agents() -> Result, String> { #[tauri::command] pub async fn write_claude_agent(agent_name: String, content: String) -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let agents_dir = home_dir.join(".claude/agents"); let agent_file_path = agents_dir.join(format!("{}.md", agent_name)); // Ensure .claude/agents directory exists - std::fs::create_dir_all(&agents_dir) - .map_err(|e| format!("Failed to create .claude/agents directory: {}", e))?; + ensure_dir(&agents_dir, ".claude/agents directory")?; std::fs::write(&agent_file_path, content) .map_err(|e| format!("Failed to write agent file: {}", e))?; @@ -2377,7 +2463,7 @@ pub async fn write_claude_agent(agent_name: String, content: String) -> Result<( #[tauri::command] pub async fn delete_claude_agent(agent_name: String) -> Result<(), String> { - let home_dir = dirs::home_dir().ok_or("Could not find home directory")?; + let home_dir = home_dir()?; let agents_dir = home_dir.join(".claude/agents"); let agent_file_path = agents_dir.join(format!("{}.md", agent_name)); @@ -2388,3 +2474,430 @@ pub async fn delete_claude_agent(agent_name: String) -> Result<(), String> { Ok(()) } + +#[tauri::command] +pub async fn read_plugin_agents() -> Result, String> { + let home_dir = home_dir()?; + let plugins_file_path = home_dir.join(".claude/plugins/installed_plugins.json"); + + if !plugins_file_path.exists() { + return Ok(vec![]); + } + + let content = std::fs::read_to_string(&plugins_file_path) + .map_err(|e| format!("Failed to read installed_plugins.json: {}", e))?; + + let installed: InstalledPluginsFile = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse installed_plugins.json: {}", e))?; + + let mut enabled_cache: std::collections::HashMap> = + std::collections::HashMap::new(); + let mut result = Vec::new(); + + for (plugin_name, installs) in installed.plugins { + for install in installs { + let enabled = if let Some(path) = + enabled_plugins_settings_path(&home_dir, &install.scope, install.project_path.as_ref()) + { + let map = enabled_cache + .entry(path.clone()) + .or_insert_with(|| read_enabled_plugins(&path).unwrap_or_default()); + map.get(&plugin_name).copied().unwrap_or(true) + } else { + true + }; + if !enabled { + continue; + } + + let packages = detect_packages(&install.install_path)?; + + // Skip if plugin doesn't have agents + if !packages.has_agents { + continue; + } + + // Walk the agents directory + let agents_dir = std::path::Path::new(&install.install_path).join("agents"); + + if !agents_dir.exists() || !agents_dir.is_dir() { + continue; + } + + // Read all .md files in the agents directory + let entries = std::fs::read_dir(&agents_dir) + .map_err(|e| format!("Failed to read agents directory: {}", e))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; + let path = entry.path(); + + if path.is_file() && path.extension().map(|ext| ext == "md").unwrap_or(false) { + let agent_name = path.file_stem() + .and_then(|name| name.to_str()) + .unwrap_or("unknown") + .to_string(); + + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read agent file {}: {}", path.display(), e))?; + + result.push(PluginAgentFile { + name: agent_name, + content, + exists: true, + plugin_name: plugin_name.clone(), + plugin_scope: install.scope.clone(), + source_path: path_to_string(&path), + }); + } + } + } + } + + // Sort agents alphabetically by name + result.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok(result) +} + +// Plugin management structures and functions + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +pub struct PluginInstallInfo { + pub scope: String, + #[serde(rename = "installPath")] + pub install_path: String, + pub version: String, + #[serde(rename = "installedAt")] + pub installed_at: String, + #[serde(rename = "lastUpdated")] + pub last_updated: String, + #[serde(rename = "gitCommitSha")] + pub git_commit_sha: String, + #[serde(rename = "projectPath", skip_serializing_if = "Option::is_none")] + pub project_path: Option, +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct InstalledPluginsFile { + pub plugins: std::collections::HashMap>, +} + +#[derive(serde::Serialize, Clone)] +pub struct PluginPackages { + #[serde(rename = "hasAgents")] + pub has_agents: bool, + #[serde(rename = "hasSkills")] + pub has_skills: bool, + #[serde(rename = "hasCommands")] + pub has_commands: bool, + #[serde(rename = "hasMcp")] + pub has_mcp: bool, +} + +#[derive(serde::Serialize)] +pub struct PluginInfo { + pub name: String, + pub scope: String, + pub version: String, + #[serde(rename = "projectPath")] + pub project_path: Option, + pub enabled: bool, + pub packages: PluginPackages, + #[serde(rename = "installPath")] + pub install_path: String, + #[serde(rename = "installedAt")] + pub installed_at: String, +} + +#[derive(serde::Serialize)] +pub struct PluginCommandFile { + pub name: String, + pub content: String, + pub exists: bool, + pub disabled: bool, + #[serde(rename = "pluginName")] + pub plugin_name: String, + #[serde(rename = "pluginScope")] + pub plugin_scope: String, + #[serde(rename = "sourcePath")] + pub source_path: String, +} + +fn detect_packages(install_path: &str) -> Result { + let path = std::path::Path::new(install_path); + + // Check if install path exists + if !path.exists() { + println!("Warning: Install path does not exist: {}", install_path); + return Ok(PluginPackages { + has_agents: false, + has_skills: false, + has_commands: false, + has_mcp: false, + }); + } + + // List contents of install path for debugging + if let Ok(entries) = std::fs::read_dir(path) { + println!("Contents of {}: ", install_path); + for entry in entries.flatten() { + if let Ok(file_type) = entry.file_type() { + let name = entry.file_name(); + let type_str = if file_type.is_dir() { "DIR" } else { "FILE" }; + println!(" {} - {:?}", type_str, name); + } + } + } + + let agents_path = path.join("agents"); + let skills_path = path.join("skills"); + let commands_path = path.join("commands"); + let mcp_path = path.join(".mcp.json"); + + let has_agents = agents_path.exists() && agents_path.is_dir(); + let has_skills = skills_path.exists() && skills_path.is_dir(); + let has_commands = commands_path.exists() && commands_path.is_dir(); + let has_mcp = mcp_path.exists() && mcp_path.is_file(); + + println!("Package detection for {}: agents={}, skills={}, commands={}, mcp={}", + install_path, has_agents, has_skills, has_commands, has_mcp); + + Ok(PluginPackages { + has_agents, + has_skills, + has_commands, + has_mcp, + }) +} + +fn read_enabled_plugins(settings_path: &std::path::Path) -> Result, String> { + let settings = read_json_file(settings_path, "settings")?; + + let mut result = std::collections::HashMap::new(); + + if let Some(enabled_plugins) = settings.get("enabledPlugins").and_then(|v| v.as_object()) { + for (key, value) in enabled_plugins { + if let Some(enabled) = value.as_bool() { + result.insert(key.clone(), enabled); + } + } + } + + Ok(result) +} + +fn enabled_plugins_settings_path( + home_dir: &std::path::Path, + scope: &str, + project_path: Option<&String>, +) -> Option { + if scope == "local" { + project_path.map(|p| PathBuf::from(p).join(".claude/settings.local.json")) + } else { + Some(home_dir.join(".claude/settings.json")) + } +} + +#[tauri::command] +pub async fn read_installed_plugins() -> Result, String> { + let home_dir = home_dir()?; + let plugins_file_path = home_dir.join(".claude/plugins/installed_plugins.json"); + + if !plugins_file_path.exists() { + return Ok(vec![]); + } + + let content = std::fs::read_to_string(&plugins_file_path) + .map_err(|e| format!("Failed to read installed_plugins.json: {}", e))?; + + let installed: InstalledPluginsFile = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse installed_plugins.json: {}", e))?; + + let mut enabled_cache: std::collections::HashMap> = + std::collections::HashMap::new(); + let mut result = Vec::new(); + + for (plugin_name, installs) in installed.plugins { + for install in installs { + let enabled = if let Some(path) = + enabled_plugins_settings_path(&home_dir, &install.scope, install.project_path.as_ref()) + { + let map = enabled_cache + .entry(path.clone()) + .or_insert_with(|| read_enabled_plugins(&path).unwrap_or_default()); + map.get(&plugin_name).copied().unwrap_or(true) + } else { + true + }; + + let packages = detect_packages(&install.install_path)?; + + println!("Plugin: {} | Scope: {} | ProjectPath: {:?}", + plugin_name, install.scope, install.project_path); + + result.push(PluginInfo { + name: plugin_name.clone(), + scope: install.scope, + version: install.version, + project_path: install.project_path, + enabled, + packages, + install_path: install.install_path, + installed_at: install.installed_at.clone(), + }); + } + } + + Ok(result) +} + +#[tauri::command] +pub async fn toggle_plugin( + plugin_name: String, + enabled: bool, + scope: String, + project_path: Option +) -> Result<(), String> { + let home_dir = home_dir()?; + + // Determine settings file based on scope + let settings_path = if scope == "local" { + if let Some(proj_path) = project_path { + std::path::PathBuf::from(proj_path).join(".claude/settings.local.json") + } else { + return Err("Project path required for local scope".to_string()); + } + } else { + home_dir.join(".claude/settings.json") + }; + + // Ensure directory exists + if let Some(parent) = settings_path.parent() { + if !parent.exists() { + ensure_dir(parent, "directory")?; + } + } + + // Read or create settings + let mut settings = read_json_file(&settings_path, "settings")?; + + // Update enabledPlugins + let settings_obj = settings.as_object_mut() + .ok_or("Settings is not an object")?; + + let enabled_plugins = settings_obj + .entry("enabledPlugins".to_string()) + .or_insert_with(|| Value::Object(serde_json::Map::new())) + .as_object_mut() + .ok_or("enabledPlugins is not an object")?; + + enabled_plugins.insert(plugin_name, Value::Bool(enabled)); + + // Write back + write_json_file(&settings_path, &settings, "settings")?; + Ok(()) +} + +#[tauri::command] +pub async fn read_plugin_commands() -> Result, String> { + let home_dir = home_dir()?; + let plugins_file_path = home_dir.join(".claude/plugins/installed_plugins.json"); + + if !plugins_file_path.exists() { + return Ok(vec![]); + } + + let content = std::fs::read_to_string(&plugins_file_path) + .map_err(|e| format!("Failed to read installed_plugins.json: {}", e))?; + + let installed: InstalledPluginsFile = serde_json::from_str(&content) + .map_err(|e| format!("Failed to parse installed_plugins.json: {}", e))?; + + let mut enabled_cache: std::collections::HashMap> = + std::collections::HashMap::new(); + let mut result = Vec::new(); + + for (plugin_name, installs) in installed.plugins { + for install in installs { + let enabled = if let Some(path) = + enabled_plugins_settings_path(&home_dir, &install.scope, install.project_path.as_ref()) + { + let map = enabled_cache + .entry(path.clone()) + .or_insert_with(|| read_enabled_plugins(&path).unwrap_or_default()); + map.get(&plugin_name).copied().unwrap_or(true) + } else { + true + }; + if !enabled { + continue; + } + + let packages = detect_packages(&install.install_path)?; + + // Skip if plugin doesn't have commands + if !packages.has_commands { + continue; + } + + // Walk the commands directory + let commands_dir = std::path::Path::new(&install.install_path).join("commands"); + + if !commands_dir.exists() || !commands_dir.is_dir() { + continue; + } + + // Read all .md and .md.disabled files in the commands directory + let entries = std::fs::read_dir(&commands_dir) + .map_err(|e| format!("Failed to read commands directory: {}", e))?; + + for entry in entries { + let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?; + let path = entry.path(); + + if path.is_file() { + let file_name_str = path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or(""); + + // Check if it's a .md or .md.disabled file + let (is_command_file, is_disabled) = if file_name_str.ends_with(".md.disabled") { + (true, true) + } else if file_name_str.ends_with(".md") { + (true, false) + } else { + (false, false) + }; + + if is_command_file { + // Extract the command name (without .md or .md.disabled) + let command_name = if is_disabled { + file_name_str.trim_end_matches(".md.disabled").to_string() + } else { + file_name_str.trim_end_matches(".md").to_string() + }; + + let content = std::fs::read_to_string(&path) + .map_err(|e| format!("Failed to read command file {}: {}", path.display(), e))?; + + result.push(PluginCommandFile { + name: command_name, + content, + exists: true, + disabled: is_disabled, + plugin_name: plugin_name.clone(), + plugin_scope: install.scope.clone(), + source_path: path_to_string(&path), + }); + } + } + } + } + } + + // Sort commands alphabetically by name + result.sort_by(|a, b| a.name.cmp(&b.name)); + + Ok(result) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index dd43547..f784e62 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod commands; +mod helper; mod tray; mod hook_server; @@ -186,6 +187,7 @@ pub fn run() { check_mcp_server_exists, get_mcp_enabled_state, toggle_mcp_server_state, + toggle_direct_mcp_server, get_mcp_servers_with_state, read_claude_projects, read_claude_config_file, @@ -209,7 +211,11 @@ pub fn run() { toggle_claude_command, read_claude_agents, write_claude_agent, - delete_claude_agent + delete_claude_agent, + read_installed_plugins, + toggle_plugin, + read_plugin_commands, + read_plugin_agents ]) .on_window_event(|window, event| { #[cfg(target_os = "macos")] diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index a62dddf..122abd0 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -6,6 +6,7 @@ import { CpuIcon, FileJsonIcon, FolderIcon, + PackageIcon, SettingsIcon, TerminalIcon, } from "lucide-react"; @@ -52,6 +53,11 @@ export function Layout() { icon: TerminalIcon, label: t("navigation.commands"), }, + { + to: "/plugins", + icon: PackageIcon, + label: t("navigation.plugins"), + }, { to: "/notification", icon: BellIcon, diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 92f643e..986f13e 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -19,6 +19,8 @@ "agents.noAgents": "No sub agents found. Click \"Add Agent\" to create one.", "agents.save": "Save", "agents.saving": "Saving...", + "agents.sourceUser": "User", + "agents.sourcePlugin": "Plugin", "agents.title": "Agents", "agents.validationError": "Validation Error", "app.title": "CC Mate", @@ -46,6 +48,8 @@ "commands.noCommands": "No custom commands found. Click \"Add Command\" to create one.", "commands.save": "Save", "commands.saving": "Saving...", + "commands.sourceUser": "User", + "commands.sourcePlugin": "Plugin", "commands.title": "Commands", "commands.validationError": "Validation Error", "commands.viewInClaude": "View in Claude", @@ -72,6 +76,7 @@ "configSwitcher.originalConfig": "Claude", "configSwitcher.originalConfigDescription": "Claude Code Original Configuration", "configSwitcher.title": "Configurations", + "error.title": "Error", "error.save": "Failed to save configuration", "error.validation": "Invalid JSON format", "glm.buyFromOfficial": "Buy from Official Website", @@ -136,6 +141,10 @@ "mcp.runtimeDisabled": "Runtime Disabled", "mcp.scope": "Scope", "mcp.sourceType": "Source", + "mcp.projectScope": "Project", + "mcp.globalScope": "Global (User Scope)", + "mcp.searchProject": "Search projects...", + "mcp.noProjectFound": "No project found.", "mcp.invalidConfigError": "Configuration must be a JSON object.", "mcp.invalidJsonError": "Invalid JSON configuration for {{serverName}}", "mcp.invalidJsonTitle": "Invalid JSON", @@ -173,6 +182,7 @@ "navigation.mcp": "MCP", "navigation.memory": "Memory", "navigation.notifications": "Notifications", + "navigation.plugins": "Plugins", "navigation.projects": "Projects", "navigation.settings": "Settings", "navigation.usage": "Usage", @@ -188,6 +198,28 @@ "notifications.title": "Notifications", "notifications.toolUse": "Tool Use Notifications", "notifications.toolUseDescription": "Notify when Claude Code is using tools", + "plugins.title": "Plugins", + "plugins.description": "Manage Claude Code plugins", + "plugins.error": "Error loading plugins: {{error}}", + "plugins.noPlugins": "No plugins installed", + "plugins.none": "None", + "plugins.scope": "Scope", + "plugins.scopeUser": "Global", + "plugins.scopeLocal": "Local", + "plugins.status": "Status", + "plugins.enabled": "Enabled", + "plugins.disabled": "Disabled", + "plugins.enable": "Enable", + "plugins.disable": "Disable", + "plugins.packages": "Packages", + "plugins.packageAgents": "Agents", + "plugins.packageSkills": "Skills", + "plugins.packageCommands": "Commands", + "plugins.packageMcp": "MCP", + "plugins.projectPath": "Project Path", + "plugins.version": "Version", + "plugins.toggleLocalError": "Cannot toggle local plugin \"{{pluginName}}\" - project path is missing", + "plugins.validationError": "Validation Error", "projects.detail.backToProjects": "Back to Projects", "projects.detail.editor": "Project Configuration Editor", "projects.detail.invalidJson": "Invalid JSON format. Please fix the errors before saving.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index e7a8d85..350377b 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -19,6 +19,8 @@ "agents.noAgents": "Aucun sub agent trouvé. Cliquez sur \"Add Agent\" pour en créer un.", "agents.save": "Sauvegarder", "agents.saving": "Sauvegarde...", + "agents.sourceUser": "Utilisateur", + "agents.sourcePlugin": "Plugin", "agents.title": "Agent", "agents.validationError": "Erreur de validation", "app.title": "CC Mate", @@ -42,6 +44,8 @@ "commands.noCommands": "Aucune commande personnalisée trouvée. Cliquez sur \"Ajouter une commande\" pour en créer une.", "commands.save": "Sauvegarder", "commands.saving": "Sauvegarde...", + "commands.sourceUser": "Utilisateur", + "commands.sourcePlugin": "Plugin", "commands.title": "Commandes", "commands.validationError": "Erreur de validation", "commands.viewInClaude": "Voir dans Claude", @@ -68,6 +72,7 @@ "configSwitcher.originalConfig": "Claude", "configSwitcher.originalConfigDescription": "Configuration d'origine de Claude Code", "configSwitcher.title": "Configurations", + "error.title": "Erreur", "error.save": "Échec de la sauvegarde de la configuration", "error.validation": "Format JSON invalide", "glm.buyFromOfficial": "Acheter sur le site officiel", @@ -120,6 +125,10 @@ "mcp.runtimeDisabled": "Désactivé au runtime", "mcp.scope": "Portée", "mcp.sourceType": "Source", + "mcp.projectScope": "Projet", + "mcp.globalScope": "Global (Portée utilisateur)", + "mcp.searchProject": "Rechercher des projets...", + "mcp.noProjectFound": "Aucun projet trouvé.", "mcp.invalidConfigError": "La configuration doit être un objet JSON.", "mcp.invalidJsonError": "Configuration JSON invalide pour {{serverName}}", "mcp.invalidJsonTitle": "JSON invalide", @@ -157,6 +166,7 @@ "navigation.mcp": "MCP", "navigation.memory": "Mémoire", "navigation.notifications": "Notifications", + "navigation.plugins": "Plugins", "navigation.projects": "Projets", "navigation.settings": "Paramètres", "navigation.usage": "Utilisation", @@ -172,6 +182,28 @@ "notifications.title": "Notifications", "notifications.toolUse": "Notifications d'utilisation d'outils", "notifications.toolUseDescription": "Notifier lorsque Claude Code utilise des outils", + "plugins.title": "Plugins", + "plugins.description": "Gérer les plugins Claude Code", + "plugins.error": "Erreur lors du chargement des plugins : {{error}}", + "plugins.noPlugins": "Aucun plugin installé", + "plugins.none": "Aucun", + "plugins.scope": "Portée", + "plugins.scopeUser": "Global", + "plugins.scopeLocal": "Local", + "plugins.status": "Statut", + "plugins.enabled": "Activé", + "plugins.disabled": "Désactivé", + "plugins.enable": "Activer", + "plugins.disable": "Désactiver", + "plugins.packages": "Packages", + "plugins.packageAgents": "Agents", + "plugins.packageSkills": "Compétences", + "plugins.packageCommands": "Commandes", + "plugins.packageMcp": "MCP", + "plugins.projectPath": "Chemin du projet", + "plugins.version": "Version", + "plugins.toggleLocalError": "Impossible d'activer/désactiver le plugin \"{{pluginName}}\" - chemin du projet manquant", + "plugins.validationError": "Erreur de validation", "projects.detail.backToProjects": "Retour aux projets", "projects.detail.editor": "Éditeur de configuration de projet", "projects.detail.invalidJson": "Format JSON invalide. Veuillez corriger les erreurs avant de sauvegarder.", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index c0f806c..7277f83 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -19,6 +19,8 @@ "agents.noAgents": "sub agent が見つかりません。「Add Agent」をクリックして作成してください。", "agents.save": "保存", "agents.saving": "保存中...", + "agents.sourceUser": "ユーザー", + "agents.sourcePlugin": "プラグイン", "agents.title": "Agent", "agents.validationError": "検証エラー", "app.title": "CC Mate", @@ -42,6 +44,8 @@ "commands.noCommands": "カスタムコマンドが見つかりません。「コマンドを追加」をクリックして作成してください。", "commands.save": "保存", "commands.saving": "保存中...", + "commands.sourceUser": "ユーザー", + "commands.sourcePlugin": "プラグイン", "commands.title": "コマンド", "commands.validationError": "検証エラー", "commands.viewInClaude": "Claude で表示", @@ -68,6 +72,7 @@ "configSwitcher.originalConfig": "Claude", "configSwitcher.originalConfigDescription": "Claude Code の元の設定", "configSwitcher.title": "設定", + "error.title": "エラー", "error.save": "設定の保存に失敗しました", "error.validation": "JSON 形式が無効です", "glm.buyFromOfficial": "公式サイトで購入", @@ -120,6 +125,10 @@ "mcp.runtimeDisabled": "ランタイム無効", "mcp.scope": "スコープ", "mcp.sourceType": "ソース", + "mcp.projectScope": "プロジェクト", + "mcp.globalScope": "グローバル(ユーザースコープ)", + "mcp.searchProject": "プロジェクトを検索...", + "mcp.noProjectFound": "プロジェクトが見つかりません。", "mcp.invalidConfigError": "設定は JSON オブジェクトである必要があります。", "mcp.invalidJsonError": "{{serverName}} の JSON 設定が無効です", "mcp.invalidJsonTitle": "JSON が無効です", @@ -157,6 +166,7 @@ "navigation.mcp": "MCP", "navigation.memory": "メモリ", "navigation.notifications": "通知", + "navigation.plugins": "プラグイン", "navigation.projects": "プロジェクト", "navigation.settings": "設定", "navigation.usage": "使用量", @@ -172,6 +182,28 @@ "notifications.title": "通知", "notifications.toolUse": "ツール使用通知", "notifications.toolUseDescription": "Claude Code がツールを使用する際に通知", + "plugins.title": "プラグイン", + "plugins.description": "Claude Code プラグインを管理", + "plugins.error": "プラグインの読み込みエラー: {{error}}", + "plugins.noPlugins": "プラグインがインストールされていません", + "plugins.none": "なし", + "plugins.scope": "スコープ", + "plugins.scopeUser": "グローバル", + "plugins.scopeLocal": "ローカル", + "plugins.status": "ステータス", + "plugins.enabled": "有効", + "plugins.disabled": "無効", + "plugins.enable": "有効化", + "plugins.disable": "無効化", + "plugins.packages": "パッケージ", + "plugins.packageAgents": "エージェント", + "plugins.packageSkills": "スキル", + "plugins.packageCommands": "コマンド", + "plugins.packageMcp": "MCP", + "plugins.projectPath": "プロジェクトパス", + "plugins.version": "バージョン", + "plugins.toggleLocalError": "ローカルプラグイン \"{{pluginName}}\" を切り替えできません - プロジェクトパスがありません", + "plugins.validationError": "検証エラー", "projects.detail.backToProjects": "プロジェクトに戻る", "projects.detail.editor": "プロジェクト設定エディター", "projects.detail.invalidJson": "JSON 形式が無効です。保存前にエラーを修正してください。", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index f5f5fef..4da57c4 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -19,6 +19,8 @@ "agents.noAgents": "未找到 sub agent。点击「Add Agent」来创建一个。", "agents.save": "保存", "agents.saving": "保存中...", + "agents.sourceUser": "用户", + "agents.sourcePlugin": "插件", "agents.title": "Agent", "agents.validationError": "验证错误", "app.title": "CC Mate", @@ -42,6 +44,8 @@ "commands.noCommands": "未找到自定义命令。点击「添加命令」来创建一个。", "commands.save": "保存", "commands.saving": "保存中...", + "commands.sourceUser": "用户", + "commands.sourcePlugin": "插件", "commands.title": "命令", "commands.validationError": "验证错误", "commands.viewInClaude": "在 Claude 中查看", @@ -68,6 +72,7 @@ "configSwitcher.originalConfig": "Claude", "configSwitcher.originalConfigDescription": "Claude Code 原有配置", "configSwitcher.title": "配置", + "error.title": "错误", "error.save": "保存配置失败", "error.validation": "JSON 格式无效", "glm.buyFromOfficial": "前往官网购买", @@ -132,6 +137,10 @@ "mcp.runtimeDisabled": "运行时禁用", "mcp.scope": "范围", "mcp.sourceType": "来源", + "mcp.projectScope": "项目", + "mcp.globalScope": "全局(用户范围)", + "mcp.searchProject": "搜索项目...", + "mcp.noProjectFound": "未找到项目。", "mcp.invalidConfigError": "配置必须是 JSON 对象。", "mcp.invalidJsonError": "{{serverName}} 的 JSON 配置无效", "mcp.invalidJsonTitle": "JSON 无效", @@ -169,6 +178,7 @@ "navigation.mcp": "MCP", "navigation.memory": "记忆", "navigation.notifications": "通知", + "navigation.plugins": "插件", "navigation.projects": "项目", "navigation.settings": "设置", "navigation.usage": "用量", @@ -184,6 +194,28 @@ "notifications.title": "通知", "notifications.toolUse": "工具使用通知", "notifications.toolUseDescription": "当 Claude Code 使用工具时提醒", + "plugins.title": "插件", + "plugins.description": "管理 Claude Code 插件", + "plugins.error": "加载插件时出错:{{error}}", + "plugins.noPlugins": "未安装插件", + "plugins.none": "无", + "plugins.scope": "范围", + "plugins.scopeUser": "全局", + "plugins.scopeLocal": "本地", + "plugins.status": "状态", + "plugins.enabled": "已启用", + "plugins.disabled": "已禁用", + "plugins.enable": "启用", + "plugins.disable": "禁用", + "plugins.packages": "包", + "plugins.packageAgents": "代理", + "plugins.packageSkills": "技能", + "plugins.packageCommands": "命令", + "plugins.packageMcp": "MCP", + "plugins.projectPath": "项目路径", + "plugins.version": "版本", + "plugins.toggleLocalError": "无法切换本地插件 \"{{pluginName}}\" - 缺少项目路径", + "plugins.validationError": "验证错误", "projects.detail.backToProjects": "返回项目列表", "projects.detail.editor": "项目配置编辑器", "projects.detail.invalidJson": "JSON 格式无效。请在保存前修复错误。", diff --git a/src/lib/codemirror-config.ts b/src/lib/codemirror-config.ts new file mode 100644 index 0000000..c7a88f4 --- /dev/null +++ b/src/lib/codemirror-config.ts @@ -0,0 +1,27 @@ +import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; +import { yamlFrontmatter } from "@codemirror/lang-yaml"; +import { EditorView } from "@uiw/react-codemirror"; + +export const codeMirrorBasicSetup = { + lineNumbers: false, + highlightActiveLineGutter: true, + foldGutter: false, + dropCursor: false, + allowMultipleSelections: false, + indentOnInput: true, + bracketMatching: true, + closeBrackets: true, + autocompletion: true, + highlightActiveLine: true, + highlightSelectionMatches: true, + searchKeymap: false, +} as const; + +export const markdownExtensions = [ + yamlFrontmatter({ + content: markdown({ + base: markdownLanguage, + }), + }), + EditorView.lineWrapping, +]; diff --git a/src/lib/query.ts b/src/lib/query.ts index cb3ac60..8afaa03 100644 --- a/src/lib/query.ts +++ b/src/lib/query.ts @@ -55,6 +55,25 @@ export interface CommandFile { disabled: boolean; } +export interface PluginCommandFile { + name: string; + content: string; + exists: boolean; + disabled: boolean; + pluginName: string; + pluginScope: string; + sourcePath: string; +} + +export interface PluginAgentFile { + name: string; + content: string; + exists: boolean; + pluginName: string; + pluginScope: string; + sourcePath: string; +} + export const useConfigFiles = () => { return useQuery({ queryKey: ["config-files"], @@ -400,32 +419,42 @@ export const useDeleteGlobalMcpServer = () => { export interface McpEnabledState { enabledMcpjsonServers: string[]; disabledMcpjsonServers: string[]; + disabledMcpServers: string[]; } -export const useGetMcpEnabledState = () => { +export const useGetMcpEnabledState = (cwd?: string) => { return useSuspenseQuery({ - queryKey: ["mcp-enabled-state"], - queryFn: () => invoke("get_mcp_enabled_state"), + queryKey: ["mcp-enabled-state", cwd], + queryFn: () => invoke("get_mcp_enabled_state", { cwd }), }); }; -export const useToggleMcpServer = () => { +export const useToggleMcpServer = (cwd?: string) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: ({ serverName, enabled, + sourceType, }: { serverName: string; enabled: boolean; - }) => invoke("toggle_mcp_server_state", { serverName, enabled }), + sourceType: "mcpjson" | "direct" | "plugin"; + }) => { + if (sourceType === "direct") { + return invoke("toggle_direct_mcp_server", { serverName, enabled, cwd }); + } else { + // Both "mcpjson" and "plugin" use the same toggle mechanism + return invoke("toggle_mcp_server_state", { serverName, enabled, cwd }); + } + }, onSuccess: (_, variables) => { toast.success( `MCP server ${variables.enabled ? "enabled" : "disabled"} successfully`, ); - queryClient.invalidateQueries({ queryKey: ["mcp-enabled-state"] }); - queryClient.invalidateQueries({ queryKey: ["mcp-servers-with-state"] }); + queryClient.invalidateQueries({ queryKey: ["mcp-enabled-state", cwd] }); + queryClient.invalidateQueries({ queryKey: ["mcp-servers-with-state", cwd] }); }, onError: (error) => { const errorMessage = @@ -438,8 +467,8 @@ export const useToggleMcpServer = () => { export interface McpServerState { name: string; config: Record; - sourceType: "mcpjson" | "direct"; - scope: "user" | "project" | "local"; + sourceType: "mcpjson" | "direct" | "plugin"; + scope: "user" | "project" | "local" | "plugin-user" | "plugin-local"; definedIn: string; controllable: boolean; state: "disabled" | "enabled" | "runtime-disabled"; @@ -447,10 +476,10 @@ export interface McpServerState { inDisabledArray: boolean; } -export const useGetMcpServersWithState = () => { +export const useGetMcpServersWithState = (cwd?: string) => { return useSuspenseQuery({ - queryKey: ["mcp-servers-with-state"], - queryFn: () => invoke("get_mcp_servers_with_state"), + queryKey: ["mcp-servers-with-state", cwd], + queryFn: () => invoke("get_mcp_servers_with_state", { cwd }), }); }; @@ -589,6 +618,12 @@ export const useClaudeCommands = () => queryFn: () => invoke("read_claude_commands"), }); +export const usePluginCommands = () => + useQuery({ + queryKey: ["plugin-commands"], + queryFn: () => invoke("read_plugin_commands"), + }); + export const useWriteClaudeCommand = () => { const queryClient = useQueryClient(); @@ -603,6 +638,7 @@ export const useWriteClaudeCommand = () => { onSuccess: () => { toast.success(i18n.t("toast.commandSaved")); queryClient.invalidateQueries({ queryKey: ["claude-commands"] }); + queryClient.invalidateQueries({ queryKey: ["plugin-commands"] }); }, onError: (error) => { const errorMessage = @@ -621,6 +657,7 @@ export const useDeleteClaudeCommand = () => { onSuccess: () => { toast.success(i18n.t("toast.commandDeleted")); queryClient.invalidateQueries({ queryKey: ["claude-commands"] }); + queryClient.invalidateQueries({ queryKey: ["plugin-commands"] }); }, onError: (error) => { const errorMessage = @@ -644,6 +681,7 @@ export const useToggleClaudeCommand = () => { onSuccess: () => { toast.success(i18n.t("toast.commandToggled")); queryClient.invalidateQueries({ queryKey: ["claude-commands"] }); + queryClient.invalidateQueries({ queryKey: ["plugin-commands"] }); }, onError: (error) => { const errorMessage = @@ -660,6 +698,12 @@ export const useClaudeAgents = () => queryFn: () => invoke("read_claude_agents"), }); +export const usePluginAgents = () => + useQuery({ + queryKey: ["plugin-agents"], + queryFn: () => invoke("read_plugin_agents"), + }); + export const useWriteClaudeAgent = () => { const queryClient = useQueryClient(); @@ -674,6 +718,7 @@ export const useWriteClaudeAgent = () => { onSuccess: () => { toast.success("Agent saved successfully"); queryClient.invalidateQueries({ queryKey: ["claude-agents"] }); + queryClient.invalidateQueries({ queryKey: ["plugin-agents"] }); }, onError: (error) => { const errorMessage = @@ -692,6 +737,7 @@ export const useDeleteClaudeAgent = () => { onSuccess: () => { toast.success("Agent deleted successfully"); queryClient.invalidateQueries({ queryKey: ["claude-agents"] }); + queryClient.invalidateQueries({ queryKey: ["plugin-agents"] }); }, onError: (error) => { const errorMessage = @@ -709,3 +755,63 @@ const rebuildTrayMenu = async () => { console.error("Failed to rebuild tray menu:", error); } }; + +// Plugin management types and hooks + +export interface PluginPackages { + hasAgents: boolean; + hasSkills: boolean; + hasCommands: boolean; + hasMcp: boolean; +} + +export interface PluginInfo { + name: string; + scope: "user" | "local"; + version: string; + projectPath?: string; + enabled: boolean; + packages: PluginPackages; + installPath: string; + installedAt: string; +} + +export const useInstalledPlugins = () => + useQuery({ + queryKey: ["installed-plugins"], + queryFn: () => invoke("read_installed_plugins"), + }); + +export const useTogglePlugin = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: ({ + pluginName, + enabled, + scope, + projectPath, + }: { + pluginName: string; + enabled: boolean; + scope: string; + projectPath?: string; + }) => + invoke("toggle_plugin", { + pluginName, + enabled, + scope, + projectPath, + }), + onSuccess: () => { + toast.success("Plugin status updated successfully"); + queryClient.invalidateQueries({ queryKey: ["installed-plugins"] }); + queryClient.invalidateQueries({ queryKey: ["mcp-servers-with-state"] }); + }, + onError: (error) => { + const errorMessage = + error instanceof Error ? error.message : String(error); + toast.error(`Failed to toggle plugin: ${errorMessage}`); + }, + }); +}; diff --git a/src/pages/AgentsPage.tsx b/src/pages/AgentsPage.tsx index ba4cf1f..5428854 100644 --- a/src/pages/AgentsPage.tsx +++ b/src/pages/AgentsPage.tsx @@ -1,7 +1,5 @@ -import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; -import { yamlFrontmatter } from "@codemirror/lang-yaml"; import { ask, message } from "@tauri-apps/plugin-dialog"; -import CodeMirror, { EditorView } from "@uiw/react-codemirror"; +import CodeMirror from "@uiw/react-codemirror"; import { BotIcon, PlusIcon, SaveIcon, TrashIcon } from "lucide-react"; import { Suspense, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -11,6 +9,7 @@ import { AccordionItem, AccordionTrigger, } from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -23,22 +22,57 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { codeMirrorBasicSetup, markdownExtensions } from "@/lib/codemirror-config"; import { useClaudeAgents, useDeleteClaudeAgent, + usePluginAgents, useWriteClaudeAgent, } from "@/lib/query"; import { useCodeMirrorTheme } from "@/lib/use-codemirror-theme"; +type UnifiedAgent = { + name: string; + content: string; + exists: boolean; + source: "user" | "plugin"; + pluginName?: string; + pluginScope?: string; + sourcePath: string; +}; + function AgentsPageContent() { const { t } = useTranslation(); - const { data: agents, isLoading, error } = useClaudeAgents(); + const { data: userAgents, isLoading: isLoadingUser, error: errorUser } = useClaudeAgents(); + const { data: pluginAgents, isLoading: isLoadingPlugin, error: errorPlugin } = usePluginAgents(); const writeAgent = useWriteClaudeAgent(); const deleteAgent = useDeleteClaudeAgent(); const [agentEdits, setAgentEdits] = useState>({}); const [isDialogOpen, setIsDialogOpen] = useState(false); const codeMirrorTheme = useCodeMirrorTheme(); + const isLoading = isLoadingUser || isLoadingPlugin; + const error = errorUser || errorPlugin; + + const agents: UnifiedAgent[] = [ + ...(userAgents || []).map((agent): UnifiedAgent => ({ + name: agent.name, + content: agent.content, + exists: agent.exists, + source: "user", + sourcePath: `~/.claude/agents/${agent.name}.md`, + })), + ...(pluginAgents || []).map((agent): UnifiedAgent => ({ + name: agent.name, + content: agent.content, + exists: agent.exists, + source: "plugin", + pluginName: agent.pluginName, + pluginScope: agent.pluginScope, + sourcePath: agent.sourcePath, + })), + ].sort((a, b) => a.name.localeCompare(b.name)); + if (isLoading) { return (
@@ -86,9 +120,9 @@ function AgentsPageContent() { }; return ( -
+
@@ -108,9 +142,7 @@ function AgentsPageContent() { - - {t("agents.addAgentTitle")} - + {t("agents.addAgentTitle")} {t("agents.addAgentDescription")} @@ -119,15 +151,15 @@ function AgentsPageContent() {
-
- {!agents || agents.length === 0 ? ( +
+ {agents.length === 0 ? (
{t("agents.noAgents")}
) : ( -
- +
+ {agents.map((agent) => ( -
+
{agent.name} + {agent.source === "user" ? ( + + {t("agents.sourceUser")} + + ) : ( + <> + + {agent.pluginName} + + + {agent.pluginScope === "user" ? t("plugins.scopeUser") : t("plugins.scopeLocal")} + + + )} - {`~/.claude/agents/${agent.name}.md`} + {agent.sourcePath}
@@ -147,39 +193,15 @@ function AgentsPageContent() {
handleContentChange(agent.name, value) } placeholder={t("agents.contentPlaceholder")} - extensions={[ - yamlFrontmatter({ - content: markdown({ - base: markdownLanguage, - }), - }), - EditorView.lineWrapping, - ]} - basicSetup={{ - lineNumbers: false, - highlightActiveLineGutter: true, - foldGutter: false, - dropCursor: false, - allowMultipleSelections: false, - indentOnInput: true, - bracketMatching: true, - closeBrackets: true, - autocompletion: true, - highlightActiveLine: true, - highlightSelectionMatches: true, - searchKeymap: false, - }} + extensions={markdownExtensions} + basicSetup={codeMirrorBasicSetup} />
@@ -192,7 +214,7 @@ function AgentsPageContent() { } size="sm" > - + {writeAgent.isPending ? t("agents.saving") : t("agents.save")} @@ -204,7 +226,7 @@ function AgentsPageContent() { onClick={() => handleDeleteAgent(agent.name)} disabled={deleteAgent.isPending} > - +
@@ -236,7 +258,11 @@ export function AgentsPage() { ); } -function CreateAgentPanel({ onClose }: { onClose?: () => void }) { +type CreateAgentPanelProps = { + onClose?: () => void; +}; + +function CreateAgentPanel({ onClose }: CreateAgentPanelProps) { const { t } = useTranslation(); const [agentName, setAgentName] = useState(""); const [agentContent, setAgentContent] = useState(`--- @@ -257,7 +283,6 @@ the subagent should follow.`); const codeMirrorTheme = useCodeMirrorTheme(); const handleCreateAgent = async () => { - // Validate agent name if (!agentName.trim()) { await message(t("agents.emptyNameError"), { title: t("agents.validationError"), @@ -266,9 +291,7 @@ the subagent should follow.`); return; } - // Check if agent already exists - const exists = agents?.some((agent) => agent.name === agentName); - if (exists) { + if (agents?.some((a) => a.name === agentName)) { await message(t("agents.agentExistsError", { agentName }), { title: t("agents.agentExistsTitle"), kind: "info", @@ -276,7 +299,6 @@ the subagent should follow.`); return; } - // Validate content if (!agentContent.trim()) { await message(t("agents.emptyContentError"), { title: t("agents.validationError"), @@ -324,29 +346,9 @@ the subagent should follow.`); onChange={(value) => setAgentContent(value)} height="200px" theme={codeMirrorTheme} - placeholder={t("agents.contentPlaceholder")} - extensions={[ - yamlFrontmatter({ - content: markdown({ - base: markdownLanguage, - }), - }), - EditorView.lineWrapping, - ]} - basicSetup={{ - lineNumbers: false, - highlightActiveLineGutter: true, - foldGutter: false, - dropCursor: false, - allowMultipleSelections: false, - indentOnInput: true, - bracketMatching: true, - closeBrackets: true, - autocompletion: true, - highlightActiveLine: true, - highlightSelectionMatches: true, - searchKeymap: false, - }} + placeholder={t("agents.contentPlaceholder")} + extensions={markdownExtensions} + basicSetup={codeMirrorBasicSetup} />
diff --git a/src/pages/CommandsPage.tsx b/src/pages/CommandsPage.tsx index 791492a..e7b6c75 100644 --- a/src/pages/CommandsPage.tsx +++ b/src/pages/CommandsPage.tsx @@ -1,7 +1,5 @@ -import { markdown, markdownLanguage } from "@codemirror/lang-markdown"; -import { yamlFrontmatter } from "@codemirror/lang-yaml"; import { ask, message } from "@tauri-apps/plugin-dialog"; -import CodeMirror, { EditorView } from "@uiw/react-codemirror"; +import CodeMirror from "@uiw/react-codemirror"; import { PlusIcon, SaveIcon, TerminalIcon, TrashIcon } from "lucide-react"; import { Suspense, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -24,17 +22,31 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ScrollArea } from "@/components/ui/scroll-area"; +import { codeMirrorBasicSetup, markdownExtensions } from "@/lib/codemirror-config"; import { useClaudeCommands, useDeleteClaudeCommand, + usePluginCommands, useToggleClaudeCommand, useWriteClaudeCommand, } from "@/lib/query"; import { useCodeMirrorTheme } from "@/lib/use-codemirror-theme"; +type UnifiedCommand = { + name: string; + content: string; + exists: boolean; + disabled: boolean; + source: "user" | "plugin"; + pluginName?: string; + pluginScope?: string; + sourcePath: string; +}; + function CommandsPageContent() { const { t } = useTranslation(); - const { data: commands, isLoading, error } = useClaudeCommands(); + const { data: userCommands, isLoading: isLoadingUser, error: errorUser } = useClaudeCommands(); + const { data: pluginCommands, isLoading: isLoadingPlugin, error: errorPlugin } = usePluginCommands(); const writeCommand = useWriteClaudeCommand(); const deleteCommand = useDeleteClaudeCommand(); const toggleCommand = useToggleClaudeCommand(); @@ -42,6 +54,30 @@ function CommandsPageContent() { const [isDialogOpen, setIsDialogOpen] = useState(false); const codeMirrorTheme = useCodeMirrorTheme(); + const isLoading = isLoadingUser || isLoadingPlugin; + const error = errorUser || errorPlugin; + + const commands: UnifiedCommand[] = [ + ...(userCommands || []).map((cmd): UnifiedCommand => ({ + name: cmd.name, + content: cmd.content, + exists: cmd.exists, + disabled: cmd.disabled, + source: "user", + sourcePath: `~/.claude/commands/${cmd.name}.md${cmd.disabled ? '.disabled' : ''}`, + })), + ...(pluginCommands || []).map((cmd): UnifiedCommand => ({ + name: cmd.name, + content: cmd.content, + exists: cmd.exists, + disabled: cmd.disabled, + source: "plugin", + pluginName: cmd.pluginName, + pluginScope: cmd.pluginScope, + sourcePath: cmd.sourcePath, + })), + ].sort((a, b) => a.name.localeCompare(b.name)); + if (isLoading) { return (
@@ -93,9 +129,9 @@ function CommandsPageContent() { }; return ( -
+
@@ -115,9 +151,7 @@ function CommandsPageContent() { - - {t("commands.addCommandTitle")} - + {t("commands.addCommandTitle")} {t("commands.addCommandDescription")} @@ -126,15 +160,15 @@ function CommandsPageContent() {
-
- {!commands || commands.length === 0 ? ( +
+ {commands.length === 0 ? (
{t("commands.noCommands")}
) : ( -
- +
+ {commands.map((command) => ( -
+
{command.name} {command.disabled ? t("commands.disabled") : t("commands.enabled")} + {command.source === "user" ? ( + + {t("commands.sourceUser")} + + ) : ( + <> + + {command.pluginName} + + + {command.pluginScope === "user" ? t("plugins.scopeUser") : t("plugins.scopeLocal")} + + + )} - {`~/.claude/commands/${command.name}.md${command.disabled ? '.disabled' : ''}`} + {command.sourcePath}
@@ -157,39 +205,15 @@ function CommandsPageContent() {
handleContentChange(command.name, value) } placeholder={t("commands.contentPlaceholder")} - extensions={[ - yamlFrontmatter({ - content: markdown({ - base: markdownLanguage, - }), - }), - EditorView.lineWrapping, - ]} - basicSetup={{ - lineNumbers: false, - highlightActiveLineGutter: true, - foldGutter: false, - dropCursor: false, - allowMultipleSelections: false, - indentOnInput: true, - bracketMatching: true, - closeBrackets: true, - autocompletion: true, - highlightActiveLine: true, - highlightSelectionMatches: true, - searchKeymap: false, - }} + extensions={markdownExtensions} + basicSetup={codeMirrorBasicSetup} />
@@ -203,7 +227,7 @@ function CommandsPageContent() { } size="sm" > - + {writeCommand.isPending ? t("commands.saving") : t("commands.save")} @@ -225,7 +249,7 @@ function CommandsPageContent() { onClick={() => handleDeleteCommand(command.name)} disabled={deleteCommand.isPending} > - +
@@ -257,7 +281,11 @@ export function CommandsPage() { ); } -function CreateCommandPanel({ onClose }: { onClose?: () => void }) { +type CreateCommandPanelProps = { + onClose?: () => void; +}; + +function CreateCommandPanel({ onClose }: CreateCommandPanelProps) { const { t } = useTranslation(); const [commandName, setCommandName] = useState(""); const [commandContent, setCommandContent] = useState(""); @@ -266,7 +294,6 @@ function CreateCommandPanel({ onClose }: { onClose?: () => void }) { const codeMirrorTheme = useCodeMirrorTheme(); const handleCreateCommand = async () => { - // Validate command name if (!commandName.trim()) { await message(t("commands.emptyNameError"), { title: t("commands.validationError"), @@ -275,9 +302,7 @@ function CreateCommandPanel({ onClose }: { onClose?: () => void }) { return; } - // Check if command already exists - const exists = commands && commands.some((cmd) => cmd.name === commandName); - if (exists) { + if (commands?.some((cmd) => cmd.name === commandName)) { await message(t("commands.commandExistsError", { commandName }), { title: t("commands.commandExistsTitle"), kind: "info", @@ -285,7 +310,6 @@ function CreateCommandPanel({ onClose }: { onClose?: () => void }) { return; } - // Validate content if (!commandContent.trim()) { await message(t("commands.emptyContentError"), { title: t("commands.validationError"), @@ -329,29 +353,9 @@ function CreateCommandPanel({ onClose }: { onClose?: () => void }) { onChange={(value) => setCommandContent(value)} height="200px" theme={codeMirrorTheme} - placeholder={t("commands.contentPlaceholder")} - extensions={[ - yamlFrontmatter({ - content: markdown({ - base: markdownLanguage, - }), - }), - EditorView.lineWrapping, - ]} - basicSetup={{ - lineNumbers: false, - highlightActiveLineGutter: true, - foldGutter: false, - dropCursor: false, - allowMultipleSelections: false, - indentOnInput: true, - bracketMatching: true, - closeBrackets: true, - autocompletion: true, - highlightActiveLine: true, - highlightSelectionMatches: true, - searchKeymap: false, - }} + placeholder={t("commands.contentPlaceholder")} + extensions={markdownExtensions} + basicSetup={codeMirrorBasicSetup} />
diff --git a/src/pages/MCPPage.tsx b/src/pages/MCPPage.tsx index b97a607..3497f60 100644 --- a/src/pages/MCPPage.tsx +++ b/src/pages/MCPPage.tsx @@ -3,7 +3,10 @@ import { ask, message } from "@tauri-apps/plugin-dialog"; import { openUrl } from "@tauri-apps/plugin-opener"; import CodeMirror from "@uiw/react-codemirror"; import { + Check, + ChevronsUpDown, ExternalLinkIcon, + FolderIcon, HammerIcon, PlusIcon, SaveIcon, @@ -11,7 +14,6 @@ import { } from "lucide-react"; import { Suspense, useState } from "react"; import { useTranslation } from "react-i18next"; -import { match } from "ts-pattern"; import { Accordion, AccordionContent, @@ -20,6 +22,14 @@ import { } from "@/components/ui/accordion"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { Dialog, DialogContent, @@ -28,31 +38,40 @@ import { DialogTitle, DialogTrigger, } from "@/components/ui/dialog"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { builtInMcpServers } from "@/lib/builtInMCP"; import { type McpServerState, useAddGlobalMcpServer, + useClaudeProjects, useDeleteGlobalMcpServer, useGetMcpServersWithState, useToggleMcpServer, useUpdateGlobalMcpServer, } from "@/lib/query"; import { useCodeMirrorTheme } from "@/lib/use-codemirror-theme"; +import { cn } from "@/lib/utils"; function MCPPageContent() { const { t } = useTranslation(); - const { data: mcpServersWithState } = useGetMcpServersWithState(); + const { data: projects } = useClaudeProjects(); + const [currentCwd, setCurrentCwd] = useState(undefined); + const { data: mcpServersWithState } = useGetMcpServersWithState(currentCwd); const updateMcpServer = useUpdateGlobalMcpServer(); const deleteMcpServer = useDeleteGlobalMcpServer(); - const toggleMcpServer = useToggleMcpServer(); + const toggleMcpServer = useToggleMcpServer(currentCwd); const [serverConfigs, setServerConfigs] = useState>( {}, ); const [isDialogOpen, setIsDialogOpen] = useState(false); + const [comboboxOpen, setComboboxOpen] = useState(false); const codeMirrorTheme = useCodeMirrorTheme(); - // Helper functions for badge display - const getBadgeVariant = (state: string) => { + function getBadgeVariant(state: string) { switch (state) { case "enabled": return "success"; @@ -60,12 +79,12 @@ function MCPPageContent() { return "outline"; case "runtime-disabled": return "secondary"; - default: - return "outline"; - } - }; + default: + return "outline"; + } + } - const getBadgeLabel = (state: string) => { + function getBadgeLabel(state: string) { switch (state) { case "enabled": return t("mcp.enabled"); @@ -73,18 +92,17 @@ function MCPPageContent() { return t("mcp.disabled"); case "runtime-disabled": return t("mcp.runtimeDisabled"); - default: - return t("mcp.disabled"); - } - }; + default: + return t("mcp.disabled"); + } + } const handleToggleServer = async (server: McpServerState) => { - if (!server.controllable) return; - const currentlyEnabled = server.state === "enabled" || server.state === "runtime-disabled"; toggleMcpServer.mutate({ serverName: server.name, enabled: !currentlyEnabled, + sourceType: server.sourceType, }); }; @@ -114,7 +132,6 @@ function MCPPageContent() { }; const handleDeleteServer = async (serverName: string) => { - // Show confirmation dialog const confirmed = await ask(t("mcp.deleteServerConfirm", { serverName }), { title: t("mcp.deleteServerTitle"), kind: "warning", @@ -125,18 +142,23 @@ function MCPPageContent() { } }; - const formatConfigForDisplay = (server: McpServerState): string => { + function formatConfigForDisplay(server: McpServerState): string { return JSON.stringify(server.config, null, 2); - }; + } + + function getPluginNameFromDefinedIn(definedIn: string): string { + const match = definedIn.match(/Plugin: (.+?) \(/); + return match ? match[1] : "Plugin"; + } const sortedServers = [...mcpServersWithState].sort((a, b) => a.name.localeCompare(b.name), ); return ( -
+
@@ -166,24 +188,97 @@ function MCPPageContent() {
setIsDialogOpen(false)} />
- {/*
- -
*/}
-
+ {projects && projects.length > 0 && ( +
+
+ + {t("mcp.projectScope")}: + + + + + + + + + + {t("mcp.noProjectFound")} + + { + setCurrentCwd(undefined); + setComboboxOpen(false); + }} + > + {t("mcp.globalScope")} + + + {projects.map((project) => ( + { + setCurrentCwd(project.path); + setComboboxOpen(false); + }} + > + + {project.path} + + + ))} + + + + + +
+
+ )} +
{sortedServers.length === 0 ? (
{t("mcp.noServersConfigured")}
) : ( - + {sortedServers.map((server) => ( -
+
{server.name} {getBadgeLabel(server.state)} - - {server.sourceType} - + {server.sourceType === "plugin" ? ( + <> + + {getPluginNameFromDefinedIn(server.definedIn)} + + + {server.scope === "plugin-user" ? t("plugins.scopeUser") : t("plugins.scopeLocal")} + + + ) : ( + + {server.sourceType} + + )}
@@ -219,7 +325,7 @@ function MCPPageContent() { placeholder="Enter MCP server configuration as JSON" />
-
+
@@ -249,12 +355,7 @@ function MCPPageContent() { } size="sm" onClick={() => handleToggleServer(server)} - disabled={!server.controllable || toggleMcpServer.isPending} - title={ - !server.controllable - ? t("mcp.notControllable") - : undefined - } + disabled={toggleMcpServer.isPending} > {server.state === "enabled" ? t("mcp.disable") @@ -273,11 +374,13 @@ function MCPPageContent() { } export function MCPPage() { + const { t } = useTranslation(); + return ( -
Loading MCP servers...
+
{t("loading")}
} > @@ -286,14 +389,18 @@ export function MCPPage() { ); } -function MCPCreatePanel({ onClose }: { onClose?: () => void }) { +type MCPCreatePanelProps = { + onClose?: () => void; +}; + +function MCPCreatePanel({ onClose }: MCPCreatePanelProps) { const { t } = useTranslation(); const [currentTab, setCurrentTab] = useState<"recommend" | "manual">( "recommend", ); return ( -
+
- {match(currentTab) - .with("recommend", () => { - return ; - }) - .with("manual", () => { - return ; - }) - .exhaustive()} + {currentTab === "recommend" ? ( + + ) : ( + + )}
); } -function RecommendMCPPanel({ onClose }: { onClose?: () => void }) { +type RecommendMCPPanelProps = { + onClose?: () => void; +}; + +function RecommendMCPPanel({ onClose }: RecommendMCPPanelProps) { const { t } = useTranslation(); const addMcpServer = useAddGlobalMcpServer(); const { data: mcpServersWithState } = useGetMcpServersWithState(); @@ -334,9 +442,8 @@ function RecommendMCPPanel({ onClose }: { onClose?: () => void }) { mcpServer: (typeof builtInMcpServers)[0], ) => { try { - // Check if MCP server already exists using cached data const exists = - mcpServersWithState && mcpServersWithState.some(s => s.name === mcpServer.name); + mcpServersWithState?.some((s) => s.name === mcpServer.name); if (exists) { await message( @@ -349,14 +456,12 @@ function RecommendMCPPanel({ onClose }: { onClose?: () => void }) { return; } - // Show confirmation dialog const confirmed = await ask( t("mcp.addServerConfirm", { serverName: mcpServer.name }), { title: t("mcp.addServerTitle"), kind: "info" }, ); if (confirmed) { - // Parse the prefill JSON to get the config object const configObject = JSON.parse(`{${mcpServer.prefill}}`); addMcpServer.mutate( @@ -366,7 +471,6 @@ function RecommendMCPPanel({ onClose }: { onClose?: () => void }) { }, { onSuccess: () => { - // Close dialog after successful addition onClose?.(); }, }, @@ -375,7 +479,7 @@ function RecommendMCPPanel({ onClose }: { onClose?: () => void }) { } catch (error) { console.error("Failed to add MCP server:", error); await message(t("mcp.addServerError"), { - title: "Error", + title: t("error.title"), kind: "error", }); } @@ -402,15 +506,10 @@ function RecommendMCPPanel({ onClose }: { onClose?: () => void }) { {t("mcp.source")}
-

{mcpServer.description}

- {/* */}
))} @@ -418,7 +517,11 @@ function RecommendMCPPanel({ onClose }: { onClose?: () => void }) { ); } -function CustomMCPPanel({ onClose }: { onClose?: () => void }) { +type CustomMCPPanelProps = { + onClose?: () => void; +}; + +function CustomMCPPanel({ onClose }: CustomMCPPanelProps) { const { t } = useTranslation(); const [customConfig, setCustomConfig] = useState(""); const addMcpServer = useAddGlobalMcpServer(); @@ -427,11 +530,10 @@ function CustomMCPPanel({ onClose }: { onClose?: () => void }) { const handleAddCustomMcpServer = async () => { try { - // Validate JSON format let configObject; try { configObject = JSON.parse(customConfig); - } catch (error) { + } catch { await message(t("mcp.addCustomServerError"), { title: t("mcp.invalidJsonTitle"), kind: "error", @@ -439,10 +541,9 @@ function CustomMCPPanel({ onClose }: { onClose?: () => void }) { return; } - // Check if it's an object with at least one server if (typeof configObject !== "object" || configObject === null) { await message(t("mcp.invalidConfigError"), { - title: "Invalid Configuration", + title: t("mcp.invalidJsonTitle"), kind: "error", }); return; @@ -451,14 +552,13 @@ function CustomMCPPanel({ onClose }: { onClose?: () => void }) { const serverNames = Object.keys(configObject); if (serverNames.length === 0) { await message(t("mcp.noServersError"), { - title: "Invalid Configuration", + title: t("mcp.invalidJsonTitle"), kind: "error", }); return; } - // Check for duplicate server names - const existingNames = mcpServersWithState ? mcpServersWithState.map(s => s.name) : []; + const existingNames = mcpServersWithState?.map((s) => s.name) ?? []; const duplicateNames = serverNames.filter((name) => existingNames.includes(name), ); @@ -476,14 +576,12 @@ function CustomMCPPanel({ onClose }: { onClose?: () => void }) { return; } - // Show confirmation dialog const confirmed = await ask( t("mcp.addCustomServersConfirm", { count: serverNames.length }), { title: t("mcp.addCustomServersTitle"), kind: "info" }, ); if (confirmed) { - // Add each server for (const [serverName, serverConfig] of Object.entries(configObject)) { addMcpServer.mutate({ serverName, @@ -491,21 +589,20 @@ function CustomMCPPanel({ onClose }: { onClose?: () => void }) { }); } - // Clear input and close dialog setCustomConfig(""); onClose?.(); } } catch (error) { console.error("Failed to add custom MCP servers:", error); await message(t("mcp.addServerError"), { - title: "Error", + title: t("error.title"), kind: "error", }); } }; return ( -
+
+
{t("loading")}
+
+ ); + } + + if (error) { + return ( +
+
+ {t("plugins.error", { error: error.message })} +
+
+ ); + } + + const sortedPlugins = [...(plugins ?? [])].sort((a, b) => { + const aDate = a.installedAt ?? ""; + const bDate = b.installedAt ?? ""; + return bDate.localeCompare(aDate); + }); + + const handleTogglePlugin = async ( + pluginName: string, + currentEnabled: boolean, + scope: string, + projectPath?: string, + ) => { + if (scope === "local" && !projectPath) { + await message(t("plugins.toggleLocalError", { pluginName }), { + title: t("plugins.validationError"), + kind: "error", + }); + return; + } + + togglePlugin.mutate({ + pluginName, + enabled: !currentEnabled, + scope, + projectPath, + }); + }; + + return ( +
+
+
+

+ {t("plugins.title")} +

+

+ {t("plugins.description")} +

+
+
+
+ {!plugins || plugins.length === 0 ? ( +
+ {t("plugins.noPlugins")} +
+ ) : ( + +
+ + {sortedPlugins.map((plugin) => ( + + +
+
+ + {plugin.name} + + {plugin.scope === "user" + ? t("plugins.scopeUser") + : t("plugins.scopeLocal")} + + + {plugin.enabled + ? t("plugins.enabled") + : t("plugins.disabled")} + + {plugin.scope === "local" && plugin.projectPath ? ( + + {plugin.projectPath} + + ) : ( + + v{plugin.version} + + )} +
+
+
+ +
+
+ + {t("plugins.packages")}: + + {plugin.packages.hasAgents && ( + + + {t("plugins.packageAgents")} + + )} + {plugin.packages.hasSkills && ( + + {t("plugins.packageSkills")} + + )} + {plugin.packages.hasCommands && ( + + + {t("plugins.packageCommands")} + + )} + {plugin.packages.hasMcp && ( + + {t("plugins.packageMcp")} + + )} + {!plugin.packages.hasAgents && + !plugin.packages.hasSkills && + !plugin.packages.hasCommands && + !plugin.packages.hasMcp && ( + + {t("plugins.none")} + + )} +
+ +
+ +
+
+
+
+ ))} +
+
+
+ )} +
+
+ ); +} + +export function PluginsPage() { + const { t } = useTranslation(); + + return ( + +
{t("loading")}
+
+ } + > + + + ); +} diff --git a/src/router.tsx b/src/router.tsx index b101a82..3cae8e9 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -8,6 +8,7 @@ import { ConfigSwitcherPage } from "./pages/ConfigSwitcherPage"; import { MCPPage } from "./pages/MCPPage"; import { MemoryPage } from "./pages/MemoryPage"; import { NotificationPage } from "./pages/NotificationPage"; +import { PluginsPage } from "./pages/PluginsPage"; import { Detail } from "./pages/projects/Detail"; import { ProjectsLayout } from "./pages/projects/Layout"; import { List } from "./pages/projects/List"; @@ -95,6 +96,14 @@ const router = createBrowserRouter([ ), }, + { + path: "plugins", + element: ( + + + + ), + }, { path: "projects", element: (