generated from nhcarrigan/template
feat: add Plugin and MCP Server Management panels
Backend (Rust): - Add plugin management commands: list, install, uninstall, enable, disable, update - Add MCP server management commands: list, get details, remove - Integrate with Claude CLI's 'plugin' and 'mcp' subcommands - Export PluginInfo and McpServerInfo types Frontend (Svelte): - Create PluginManagementPanel component with full CRUD operations - Create McpManagementPanel component with server listing and removal - Add buttons to StatusBar for both panels - Beautiful UI with lucide-svelte icons - Theme-aware styling using CSS variables - Real-time loading states and error handling Closes #133 Closes #134
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
use std::path::PathBuf;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use tauri_plugin_http::reqwest;
|
||||
use tauri_plugin_store::StoreExt;
|
||||
@@ -1257,6 +1258,313 @@ pub async fn get_claude_version() -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Plugin Management Commands ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PluginInfo {
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_plugins() -> Result<Vec<PluginInfo>, String> {
|
||||
tracing::debug!("Listing Claude Code plugins");
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("list")
|
||||
.arg("--json")
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
match serde_json::from_str::<Vec<PluginInfo>>(&stdout) {
|
||||
Ok(plugins) => {
|
||||
tracing::info!("Listed {} plugins", plugins.len());
|
||||
Ok(plugins)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to parse plugin list: {}", e);
|
||||
Err(format!("Failed to parse plugin list: {}", e))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to list plugins: {}", error);
|
||||
Err(format!("Failed to list plugins: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude plugin list: {}", e);
|
||||
Err(format!("Failed to execute claude plugin list: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn install_plugin(plugin_name: String) -> Result<String, String> {
|
||||
tracing::debug!("Installing plugin: {}", plugin_name);
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("install")
|
||||
.arg(&plugin_name)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
tracing::info!("Successfully installed plugin: {}", plugin_name);
|
||||
Ok(message)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to install plugin {}: {}", plugin_name, error);
|
||||
Err(format!("Failed to install plugin: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude plugin install: {}", e);
|
||||
Err(format!("Failed to execute claude plugin install: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn uninstall_plugin(plugin_name: String) -> Result<String, String> {
|
||||
tracing::debug!("Uninstalling plugin: {}", plugin_name);
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("uninstall")
|
||||
.arg(&plugin_name)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
tracing::info!("Successfully uninstalled plugin: {}", plugin_name);
|
||||
Ok(message)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to uninstall plugin {}: {}", plugin_name, error);
|
||||
Err(format!("Failed to uninstall plugin: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude plugin uninstall: {}", e);
|
||||
Err(format!("Failed to execute claude plugin uninstall: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn enable_plugin(plugin_name: String) -> Result<String, String> {
|
||||
tracing::debug!("Enabling plugin: {}", plugin_name);
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("enable")
|
||||
.arg(&plugin_name)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
tracing::info!("Successfully enabled plugin: {}", plugin_name);
|
||||
Ok(message)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to enable plugin {}: {}", plugin_name, error);
|
||||
Err(format!("Failed to enable plugin: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude plugin enable: {}", e);
|
||||
Err(format!("Failed to execute claude plugin enable: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disable_plugin(plugin_name: String) -> Result<String, String> {
|
||||
tracing::debug!("Disabling plugin: {}", plugin_name);
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("disable")
|
||||
.arg(&plugin_name)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
tracing::info!("Successfully disabled plugin: {}", plugin_name);
|
||||
Ok(message)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to disable plugin {}: {}", plugin_name, error);
|
||||
Err(format!("Failed to disable plugin: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude plugin disable: {}", e);
|
||||
Err(format!("Failed to execute claude plugin disable: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_plugin(plugin_name: String) -> Result<String, String> {
|
||||
tracing::debug!("Updating plugin: {}", plugin_name);
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("plugin")
|
||||
.arg("update")
|
||||
.arg(&plugin_name)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
tracing::info!("Successfully updated plugin: {}", plugin_name);
|
||||
Ok(message)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to update plugin {}: {}", plugin_name, error);
|
||||
Err(format!("Failed to update plugin: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude plugin update: {}", e);
|
||||
Err(format!("Failed to execute claude plugin update: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== MCP Management Commands ====================
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct McpServerInfo {
|
||||
pub name: String,
|
||||
pub command: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub transport: String, // "stdio", "http", or "sse"
|
||||
pub env: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_mcp_servers() -> Result<Vec<McpServerInfo>, String> {
|
||||
tracing::debug!("Listing MCP servers");
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("mcp")
|
||||
.arg("list")
|
||||
.arg("--json")
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
match serde_json::from_str::<Vec<McpServerInfo>>(&stdout) {
|
||||
Ok(servers) => {
|
||||
tracing::info!("Listed {} MCP servers", servers.len());
|
||||
Ok(servers)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to parse MCP server list: {}", e);
|
||||
Err(format!("Failed to parse MCP server list: {}", e))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to list MCP servers: {}", error);
|
||||
Err(format!("Failed to list MCP servers: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude mcp list: {}", e);
|
||||
Err(format!("Failed to execute claude mcp list: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_mcp_server(name: String) -> Result<McpServerInfo, String> {
|
||||
tracing::debug!("Getting MCP server details: {}", name);
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("mcp")
|
||||
.arg("get")
|
||||
.arg(&name)
|
||||
.arg("--json")
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
match serde_json::from_str::<McpServerInfo>(&stdout) {
|
||||
Ok(server) => {
|
||||
tracing::info!("Got MCP server details for: {}", name);
|
||||
Ok(server)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to parse MCP server details: {}", e);
|
||||
Err(format!("Failed to parse MCP server details: {}", e))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to get MCP server {}: {}", name, error);
|
||||
Err(format!("Failed to get MCP server: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude mcp get: {}", e);
|
||||
Err(format!("Failed to execute claude mcp get: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_mcp_server(name: String) -> Result<String, String> {
|
||||
tracing::debug!("Removing MCP server: {}", name);
|
||||
|
||||
let output = std::process::Command::new("claude")
|
||||
.arg("mcp")
|
||||
.arg("remove")
|
||||
.arg(&name)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(output) => {
|
||||
if output.status.success() {
|
||||
let message = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
tracing::info!("Successfully removed MCP server: {}", name);
|
||||
Ok(message)
|
||||
} else {
|
||||
let error = String::from_utf8_lossy(&output.stderr);
|
||||
tracing::error!("Failed to remove MCP server {}: {}", name, error);
|
||||
Err(format!("Failed to remove MCP server: {}", error))
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Failed to execute claude mcp remove: {}", e);
|
||||
Err(format!("Failed to execute claude mcp remove: {}", e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -195,6 +195,15 @@ pub fn run() {
|
||||
close_application,
|
||||
list_memory_files,
|
||||
get_claude_version,
|
||||
list_plugins,
|
||||
install_plugin,
|
||||
uninstall_plugin,
|
||||
enable_plugin,
|
||||
disable_plugin,
|
||||
update_plugin,
|
||||
list_mcp_servers,
|
||||
get_mcp_server,
|
||||
remove_mcp_server,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
import { Trash2, RefreshCw, Server, Globe, Terminal } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface McpServerInfo {
|
||||
name: string;
|
||||
command: string | null;
|
||||
url: string | null;
|
||||
transport: string; // "stdio", "http", or "sse"
|
||||
env: any | null;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
let servers = $state<McpServerInfo[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let selectedServer = $state<McpServerInfo | null>(null);
|
||||
let isLoadingDetails = $state(false);
|
||||
let actionInProgress = $state<string | null>(null);
|
||||
|
||||
async function loadServers(): Promise<void> {
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
servers = await invoke<McpServerInfo[]>("list_mcp_servers");
|
||||
} catch (e) {
|
||||
error = `Failed to load MCP servers: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadServerDetails(name: string): Promise<void> {
|
||||
try {
|
||||
isLoadingDetails = true;
|
||||
error = null;
|
||||
selectedServer = await invoke<McpServerInfo>("get_mcp_server", { name });
|
||||
} catch (e) {
|
||||
error = `Failed to load server details: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoadingDetails = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeServer(name: string): Promise<void> {
|
||||
try {
|
||||
actionInProgress = name;
|
||||
error = null;
|
||||
await invoke("remove_mcp_server", { name });
|
||||
if (selectedServer?.name === name) {
|
||||
selectedServer = null;
|
||||
}
|
||||
await loadServers();
|
||||
} catch (e) {
|
||||
error = `Failed to remove server: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getTransportIcon(transport: string) {
|
||||
switch (transport) {
|
||||
case "http":
|
||||
return Globe;
|
||||
case "stdio":
|
||||
return Terminal;
|
||||
case "sse":
|
||||
return Server;
|
||||
default:
|
||||
return Server;
|
||||
}
|
||||
}
|
||||
|
||||
function getTransportColor(transport: string) {
|
||||
switch (transport) {
|
||||
case "http":
|
||||
return "text-blue-400";
|
||||
case "stdio":
|
||||
return "text-green-400";
|
||||
case "sse":
|
||||
return "text-purple-400";
|
||||
default:
|
||||
return "text-[var(--text-secondary)]";
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadServers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed top-0 right-0 h-full w-[700px] bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-[var(--accent-primary)]">
|
||||
<Server class="w-6 h-6" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">MCP Server Management</h2>
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
{servers.length} server{servers.length !== 1 ? "s" : ""} configured
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
|
||||
aria-label="Close MCP panel"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Info Box -->
|
||||
<div class="mx-4 mt-4 p-3 bg-[var(--accent-primary)]/10 border border-[var(--accent-primary)]/30 rounded-lg">
|
||||
<p class="text-sm text-[var(--text-primary)]">
|
||||
💡 To add new MCP servers, use the Settings panel or edit your configuration directly.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
{#if error}
|
||||
<div class="mx-4 mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||
<p class="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 overflow-y-auto p-4 flex gap-4">
|
||||
<!-- Server List -->
|
||||
<div class="flex-1">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<RefreshCw class="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
{:else if servers.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<Server class="w-16 h-16 mb-4 opacity-50" />
|
||||
<p class="text-center">No MCP servers configured</p>
|
||||
<p class="text-sm text-center mt-2">Add servers via Settings</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each servers as server (server.name)}
|
||||
<button
|
||||
onclick={() => loadServerDetails(server.name)}
|
||||
class="w-full bg-[var(--bg-secondary)]/50 rounded-lg p-3 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all text-left"
|
||||
class:border-[var(--accent-primary)]={selectedServer?.name === server.name}
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
|
||||
<svelte:component
|
||||
this={getTransportIcon(server.transport)}
|
||||
class="w-4 h-4 {getTransportColor(server.transport)}"
|
||||
/>
|
||||
{server.name}
|
||||
</h4>
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-1">
|
||||
{server.transport.toUpperCase()}
|
||||
{#if server.url}
|
||||
• {server.url}
|
||||
{:else if server.command}
|
||||
• {server.command}
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Server Details Panel -->
|
||||
{#if selectedServer}
|
||||
<div class="w-80 bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)]">
|
||||
<h3 class="text-lg font-semibold text-[var(--text-primary)] mb-4">Server Details</h3>
|
||||
|
||||
{#if isLoadingDetails}
|
||||
<div class="flex items-center justify-center h-32">
|
||||
<RefreshCw class="w-6 h-6 animate-spin text-[var(--text-secondary)]" />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<!-- Name -->
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Name</label
|
||||
>
|
||||
<p class="text-sm text-[var(--text-primary)] mt-1">{selectedServer.name}</p>
|
||||
</div>
|
||||
|
||||
<!-- Transport -->
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Transport</label
|
||||
>
|
||||
<p class="text-sm text-[var(--text-primary)] mt-1 flex items-center gap-2">
|
||||
<svelte:component
|
||||
this={getTransportIcon(selectedServer.transport)}
|
||||
class="w-4 h-4 {getTransportColor(selectedServer.transport)}"
|
||||
/>
|
||||
{selectedServer.transport.toUpperCase()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- URL or Command -->
|
||||
{#if selectedServer.url}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>URL</label
|
||||
>
|
||||
<p
|
||||
class="text-sm text-[var(--text-primary)] mt-1 break-all font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
|
||||
>
|
||||
{selectedServer.url}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if selectedServer.command}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Command</label
|
||||
>
|
||||
<p
|
||||
class="text-sm text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)]"
|
||||
>
|
||||
{selectedServer.command}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Environment Variables -->
|
||||
{#if selectedServer.env}
|
||||
<div>
|
||||
<label class="text-xs text-[var(--text-secondary)] uppercase tracking-wider"
|
||||
>Environment</label
|
||||
>
|
||||
<pre
|
||||
class="text-xs text-[var(--text-primary)] mt-1 font-mono bg-[var(--bg-primary)] p-2 rounded border border-[var(--border-color)] overflow-x-auto">{JSON.stringify(selectedServer.env, null, 2)}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="pt-4 border-t border-[var(--border-color)]">
|
||||
<button
|
||||
onclick={() => removeServer(selectedServer.name)}
|
||||
disabled={actionInProgress === selectedServer.name}
|
||||
class="w-full px-4 py-2 bg-red-500/20 border border-red-500/30 rounded-lg text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
Remove Server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,299 @@
|
||||
<script lang="ts">
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { onMount } from "svelte";
|
||||
import { Download, Trash2, Power, PowerOff, RefreshCw } from "lucide-svelte";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface PluginInfo {
|
||||
name: string;
|
||||
version: string;
|
||||
description: string | null;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
const { onClose }: Props = $props();
|
||||
|
||||
let plugins = $state<PluginInfo[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let newPluginName = $state("");
|
||||
let isInstalling = $state(false);
|
||||
let actionInProgress = $state<string | null>(null);
|
||||
|
||||
async function loadPlugins(): Promise<void> {
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
plugins = await invoke<PluginInfo[]>("list_plugins");
|
||||
} catch (e) {
|
||||
error = `Failed to load plugins: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function installPlugin(): Promise<void> {
|
||||
if (!newPluginName.trim()) return;
|
||||
|
||||
try {
|
||||
isInstalling = true;
|
||||
error = null;
|
||||
await invoke("install_plugin", { pluginName: newPluginName.trim() });
|
||||
newPluginName = "";
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
error = `Failed to install plugin: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
isInstalling = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallPlugin(pluginName: string): Promise<void> {
|
||||
try {
|
||||
actionInProgress = pluginName;
|
||||
error = null;
|
||||
await invoke("uninstall_plugin", { pluginName });
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
error = `Failed to uninstall plugin: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePlugin(plugin: PluginInfo): Promise<void> {
|
||||
try {
|
||||
actionInProgress = plugin.name;
|
||||
error = null;
|
||||
if (plugin.enabled) {
|
||||
await invoke("disable_plugin", { pluginName: plugin.name });
|
||||
} else {
|
||||
await invoke("enable_plugin", { pluginName: plugin.name });
|
||||
}
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
error = `Failed to ${plugin.enabled ? "disable" : "enable"} plugin: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function updatePlugin(pluginName: string): Promise<void> {
|
||||
try {
|
||||
actionInProgress = pluginName;
|
||||
error = null;
|
||||
await invoke("update_plugin", { pluginName });
|
||||
await loadPlugins();
|
||||
} catch (e) {
|
||||
error = `Failed to update plugin: ${e}`;
|
||||
console.error(error);
|
||||
} finally {
|
||||
actionInProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
loadPlugins();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
class="fixed top-0 right-0 h-full w-[600px] bg-[var(--bg-primary)] border-l border-[var(--accent-primary)]/30 shadow-2xl flex flex-col z-50"
|
||||
>
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-[var(--accent-primary)]/30">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="text-[var(--accent-primary)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-[var(--text-primary)]">Plugin Management</h2>
|
||||
<p class="text-xs text-[var(--text-secondary)]">
|
||||
{plugins.length} plugin{plugins.length !== 1 ? "s" : ""} installed
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onclick={onClose}
|
||||
class="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors p-1 rounded-lg hover:bg-[var(--bg-secondary)]"
|
||||
aria-label="Close plugin panel"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-5 w-5"
|
||||
viewBox="0 0 20 20"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Install Plugin Section -->
|
||||
<div class="p-4 border-b border-[var(--border-color)]">
|
||||
<h3 class="text-sm font-medium text-[var(--text-primary)] mb-2">Install New Plugin</h3>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newPluginName}
|
||||
placeholder="Plugin name..."
|
||||
class="flex-1 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg text-[var(--text-primary)] text-sm focus:outline-none focus:border-[var(--accent-primary)]"
|
||||
onkeydown={(e) => e.key === "Enter" && installPlugin()}
|
||||
disabled={isInstalling}
|
||||
/>
|
||||
<button
|
||||
onclick={installPlugin}
|
||||
disabled={isInstalling || !newPluginName.trim()}
|
||||
class="px-4 py-2 bg-[var(--accent-primary)] text-white rounded-lg text-sm font-medium hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
{#if isInstalling}
|
||||
<RefreshCw class="w-4 h-4 animate-spin" />
|
||||
{:else}
|
||||
<Download class="w-4 h-4" />
|
||||
{/if}
|
||||
Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Display -->
|
||||
{#if error}
|
||||
<div class="mx-4 mt-4 p-3 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||
<p class="text-sm text-red-400">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Plugins List -->
|
||||
<div class="flex-1 overflow-y-auto p-4">
|
||||
{#if isLoading}
|
||||
<div class="flex items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<RefreshCw class="w-8 h-8 animate-spin" />
|
||||
</div>
|
||||
{:else if plugins.length === 0}
|
||||
<div class="flex flex-col items-center justify-center h-full text-[var(--text-secondary)]">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-16 w-16 mb-4 opacity-50"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
<p class="text-center">No plugins installed</p>
|
||||
<p class="text-sm text-center mt-2">Install a plugin using the form above</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
{#each plugins as plugin (plugin.name)}
|
||||
<div
|
||||
class="bg-[var(--bg-secondary)]/50 rounded-lg p-4 border border-[var(--border-color)] hover:border-[var(--accent-primary)]/50 transition-all"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<div class="flex-1">
|
||||
<h4 class="font-medium text-[var(--text-primary)] flex items-center gap-2">
|
||||
{plugin.name}
|
||||
{#if plugin.enabled}
|
||||
<span
|
||||
class="px-2 py-0.5 bg-[var(--success-color)]/20 text-[var(--success-color)] text-xs rounded border border-[var(--success-color)]/30"
|
||||
>
|
||||
Enabled
|
||||
</span>
|
||||
{:else}
|
||||
<span
|
||||
class="px-2 py-0.5 bg-[var(--text-secondary)]/20 text-[var(--text-secondary)] text-xs rounded border border-[var(--border-color)]"
|
||||
>
|
||||
Disabled
|
||||
</span>
|
||||
{/if}
|
||||
</h4>
|
||||
<p class="text-xs text-[var(--text-secondary)] mt-1">v{plugin.version}</p>
|
||||
{#if plugin.description}
|
||||
<p class="text-sm text-[var(--text-secondary)] mt-2">{plugin.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 mt-3">
|
||||
<button
|
||||
onclick={() => togglePlugin(plugin)}
|
||||
disabled={actionInProgress === plugin.name}
|
||||
class="flex-1 px-3 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{#if plugin.enabled}
|
||||
<PowerOff class="w-4 h-4" />
|
||||
Disable
|
||||
{:else}
|
||||
<Power class="w-4 h-4" />
|
||||
Enable
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => updatePlugin(plugin.name)}
|
||||
disabled={actionInProgress === plugin.name}
|
||||
class="px-3 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] hover:border-[var(--accent-primary)]/50 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw class="w-4 h-4" />
|
||||
Update
|
||||
</button>
|
||||
|
||||
<button
|
||||
onclick={() => uninstallPlugin(plugin.name)}
|
||||
disabled={actionInProgress === plugin.name}
|
||||
class="px-3 py-1.5 bg-red-500/20 border border-red-500/30 rounded text-sm text-red-400 hover:bg-red-500/30 transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<Trash2 class="w-4 h-4" />
|
||||
Uninstall
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
</style>
|
||||
@@ -27,6 +27,8 @@
|
||||
import GitPanel from "./GitPanel.svelte";
|
||||
import ProfilePanel from "./ProfilePanel.svelte";
|
||||
import AgentMonitorPanel from "./AgentMonitorPanel.svelte";
|
||||
import PluginManagementPanel from "./PluginManagementPanel.svelte";
|
||||
import McpManagementPanel from "./McpManagementPanel.svelte";
|
||||
import { conversationsStore } from "$lib/stores/conversations";
|
||||
import {
|
||||
generateContextInjection,
|
||||
@@ -54,6 +56,8 @@
|
||||
let showGitPanel = $state(false);
|
||||
let showProfile = $state(false);
|
||||
let showAgentMonitor = $state(false);
|
||||
let showPluginPanel = $state(false);
|
||||
let showMcpPanel = $state(false);
|
||||
let isSummarising = $state(false);
|
||||
const progress = $derived($achievementProgress);
|
||||
const activeAgentCount = $derived($runningAgentCount);
|
||||
@@ -468,6 +472,34 @@
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showPluginPanel = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="Plugin Management"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={() => (showMcpPanel = true)}
|
||||
class="p-1 text-gray-500 icon-trans-hover"
|
||||
title="MCP Server Management"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onclick={toggleEditor}
|
||||
disabled={connectionStatus !== "connected"}
|
||||
@@ -704,3 +736,11 @@
|
||||
{#if showAgentMonitor}
|
||||
<AgentMonitorPanel isOpen={showAgentMonitor} onClose={() => (showAgentMonitor = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showPluginPanel}
|
||||
<PluginManagementPanel onClose={() => (showPluginPanel = false)} />
|
||||
{/if}
|
||||
|
||||
{#if showMcpPanel}
|
||||
<McpManagementPanel onClose={() => (showMcpPanel = false)} />
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user