feat: CodePilot - desktop GUI for Claude Code
Full-featured desktop application built with Next.js + Electron: - Streaming chat with Claude Code via Agent SDK - Session management with SQLite persistence - Project file tree and file preview - MCP server and custom skills management - Permission controls with Code/Plan/Ask modes - Dark/light theme, token tracking - macOS DMG packaging with electron-builder Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
6776cc6297
commit
ff1fb38735
149 files changed
+31117
-122
No files matched your search
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
interface ConfigEditorProps {
|
||||
value: string;
|
||||
onSave: (value: string) => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export function ConfigEditor({ value, onSave, label }: ConfigEditorProps) {
|
||||
const [text, setText] = useState(value);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => { setText(value); }, [value]);
|
||||
|
||||
function handleSave() {
|
||||
try {
|
||||
JSON.parse(text);
|
||||
setError(null);
|
||||
onSave(text);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Invalid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
function handleFormat() {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
setText(JSON.stringify(parsed, null, 2));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Invalid JSON');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{label && <Label>{label}</Label>}
|
||||
<Textarea
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
className="font-mono text-sm min-h-[200px]"
|
||||
placeholder="{}"
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={handleFormat}>
|
||||
Format
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { PlusSignIcon, ListViewIcon, CodeIcon, Loading02Icon } from "@hugeicons/core-free-icons";
|
||||
import { McpServerList } from "@/components/plugins/McpServerList";
|
||||
import { McpServerEditor } from "@/components/plugins/McpServerEditor";
|
||||
import { ConfigEditor } from "@/components/plugins/ConfigEditor";
|
||||
import type { MCPServer } from "@/types";
|
||||
|
||||
export function McpManager() {
|
||||
const [servers, setServers] = useState<Record<string, MCPServer>>({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editingName, setEditingName] = useState<string | undefined>();
|
||||
const [editingServer, setEditingServer] = useState<MCPServer | undefined>();
|
||||
const [tab, setTab] = useState<"list" | "json">("list");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch("/api/plugins/mcp");
|
||||
const data = await res.json();
|
||||
if (data.mcpServers) {
|
||||
setServers(data.mcpServers);
|
||||
} else if (data.error) {
|
||||
setError(data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to fetch MCP servers:", err);
|
||||
setError("Failed to connect to API");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
}, [fetchServers]);
|
||||
|
||||
function handleEdit(name: string, server: MCPServer) {
|
||||
setEditingName(name);
|
||||
setEditingServer(server);
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
function handleAdd() {
|
||||
setEditingName(undefined);
|
||||
setEditingServer(undefined);
|
||||
setEditorOpen(true);
|
||||
}
|
||||
|
||||
async function handleDelete(name: string) {
|
||||
try {
|
||||
const res = await fetch(`/api/plugins/mcp/${encodeURIComponent(name)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) {
|
||||
setServers((prev) => {
|
||||
const updated = { ...prev };
|
||||
delete updated[name];
|
||||
return updated;
|
||||
});
|
||||
} else {
|
||||
const data = await res.json();
|
||||
console.error("Failed to delete MCP server:", data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to delete MCP server:", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave(name: string, server: MCPServer) {
|
||||
if (editingName && editingName !== name) {
|
||||
const updated = { ...servers };
|
||||
delete updated[editingName];
|
||||
updated[name] = server;
|
||||
try {
|
||||
await fetch("/api/plugins/mcp", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mcpServers: updated }),
|
||||
});
|
||||
setServers(updated);
|
||||
} catch (err) {
|
||||
console.error("Failed to save MCP server:", err);
|
||||
}
|
||||
} else if (editingName) {
|
||||
const updated = { ...servers, [name]: server };
|
||||
try {
|
||||
await fetch("/api/plugins/mcp", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mcpServers: updated }),
|
||||
});
|
||||
setServers(updated);
|
||||
} catch (err) {
|
||||
console.error("Failed to save MCP server:", err);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const res = await fetch("/api/plugins/mcp", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, server }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setServers((prev) => ({ ...prev, [name]: server }));
|
||||
} else {
|
||||
const data = await res.json();
|
||||
console.error("Failed to add MCP server:", data.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to add MCP server:", err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleJsonSave(jsonStr: string) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
await fetch("/api/plugins/mcp", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ mcpServers: parsed }),
|
||||
});
|
||||
setServers(parsed);
|
||||
} catch (err) {
|
||||
console.error("Failed to save MCP config:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const serverCount = Object.keys(servers).length;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold">MCP Servers</h3>
|
||||
{serverCount > 0 && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({serverCount})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-0.5">
|
||||
Configure Model Context Protocol servers for Claude
|
||||
</p>
|
||||
</div>
|
||||
<Button size="sm" className="gap-1" onClick={handleAdd}>
|
||||
<HugeiconsIcon icon={PlusSignIcon} className="h-3.5 w-3.5" />
|
||||
Add Server
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 mb-4">
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as "list" | "json")}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="list" className="gap-1.5">
|
||||
<HugeiconsIcon icon={ListViewIcon} className="h-3.5 w-3.5" />
|
||||
Servers
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="json" className="gap-1.5">
|
||||
<HugeiconsIcon icon={CodeIcon} className="h-3.5 w-3.5" />
|
||||
JSON Config
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="list" className="mt-4">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center gap-2 py-12 text-muted-foreground">
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
|
||||
<p className="text-sm">Loading MCP servers...</p>
|
||||
</div>
|
||||
) : (
|
||||
<McpServerList
|
||||
servers={servers}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="json" className="mt-4">
|
||||
<ConfigEditor
|
||||
value={JSON.stringify(servers, null, 2)}
|
||||
onSave={handleJsonSave}
|
||||
label="MCP Server Configuration"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<McpServerEditor
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
name={editingName}
|
||||
server={editingServer}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ServerStack01Icon, Wifi01Icon, GlobeIcon, CodeIcon } from "@hugeicons/core-free-icons";
|
||||
import type { MCPServer } from '@/types';
|
||||
|
||||
type ServerType = 'stdio' | 'sse' | 'http';
|
||||
|
||||
interface McpServerEditorProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
name?: string;
|
||||
server?: MCPServer;
|
||||
onSave: (name: string, server: MCPServer) => void;
|
||||
}
|
||||
|
||||
export function McpServerEditor({
|
||||
open,
|
||||
onOpenChange,
|
||||
name: initialName,
|
||||
server: initialServer,
|
||||
onSave,
|
||||
}: McpServerEditorProps) {
|
||||
const isEditing = !!initialName;
|
||||
const [name, setName] = useState(initialName || '');
|
||||
const [serverType, setServerType] = useState<ServerType>(
|
||||
initialServer?.type || 'stdio'
|
||||
);
|
||||
const [command, setCommand] = useState(initialServer?.command || '');
|
||||
const [args, setArgs] = useState(initialServer?.args?.join('\n') || '');
|
||||
const [url, setUrl] = useState(initialServer?.url || '');
|
||||
const [headersText, setHeadersText] = useState(
|
||||
initialServer?.headers ? JSON.stringify(initialServer.headers, null, 2) : '{}'
|
||||
);
|
||||
const [envText, setEnvText] = useState(
|
||||
initialServer?.env ? JSON.stringify(initialServer.env, null, 2) : '{}'
|
||||
);
|
||||
const [jsonMode, setJsonMode] = useState(false);
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset form when dialog opens with new data
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initialName || '');
|
||||
setServerType(initialServer?.type || 'stdio');
|
||||
setCommand(initialServer?.command || '');
|
||||
setArgs(initialServer?.args?.join('\n') || '');
|
||||
setUrl(initialServer?.url || '');
|
||||
setHeadersText(
|
||||
initialServer?.headers
|
||||
? JSON.stringify(initialServer.headers, null, 2)
|
||||
: '{}'
|
||||
);
|
||||
setEnvText(
|
||||
initialServer?.env
|
||||
? JSON.stringify(initialServer.env, null, 2)
|
||||
: '{}'
|
||||
);
|
||||
setJsonMode(false);
|
||||
setJsonText(
|
||||
initialServer
|
||||
? JSON.stringify(initialServer, null, 2)
|
||||
: '{\n "command": "",\n "args": []\n}'
|
||||
);
|
||||
setError(null);
|
||||
}
|
||||
}, [open, initialName, initialServer]);
|
||||
|
||||
function handleSave() {
|
||||
setError(null);
|
||||
|
||||
if (!name.trim()) {
|
||||
setError('Server name is required');
|
||||
return;
|
||||
}
|
||||
|
||||
if (jsonMode) {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
||||
setError('JSON must be an object');
|
||||
return;
|
||||
}
|
||||
onSave(name.trim(), parsed as MCPServer);
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
setError('Invalid JSON configuration');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate based on server type
|
||||
if (serverType === 'stdio') {
|
||||
if (!command.trim()) {
|
||||
setError('Command is required for stdio servers');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (!url.trim()) {
|
||||
setError('URL is required for SSE/HTTP servers');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let env: Record<string, string> | undefined;
|
||||
try {
|
||||
const parsed = JSON.parse(envText);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
env = Object.keys(parsed).length > 0 ? parsed : undefined;
|
||||
} else {
|
||||
setError('Environment must be a JSON object');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setError('Invalid JSON in environment variables');
|
||||
return;
|
||||
}
|
||||
|
||||
let headers: Record<string, string> | undefined;
|
||||
if (serverType !== 'stdio') {
|
||||
try {
|
||||
const parsed = JSON.parse(headersText);
|
||||
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
|
||||
headers = Object.keys(parsed).length > 0 ? parsed : undefined;
|
||||
} else {
|
||||
setError('Headers must be a JSON object');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
setError('Invalid JSON in headers');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const serverArgs = args
|
||||
.split('\n')
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const server: MCPServer = serverType === 'stdio'
|
||||
? {
|
||||
command: command.trim(),
|
||||
...(serverArgs.length > 0 ? { args: serverArgs } : {}),
|
||||
...(env ? { env } : {}),
|
||||
}
|
||||
: {
|
||||
command: '',
|
||||
type: serverType,
|
||||
...(url ? { url: url.trim() } : {}),
|
||||
...(serverArgs.length > 0 ? { args: serverArgs } : {}),
|
||||
...(env ? { env } : {}),
|
||||
...(headers ? { headers } : {}),
|
||||
};
|
||||
|
||||
onSave(name.trim(), server);
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[550px] max-h-[85vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditing ? `Edit MCP Server: ${initialName}` : 'Add MCP Server'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-name">Server Name</Label>
|
||||
<Input
|
||||
id="server-name"
|
||||
value={name}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="my-mcp-server"
|
||||
disabled={isEditing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="shrink-0">Edit Mode:</Label>
|
||||
<Button
|
||||
variant={jsonMode ? 'outline' : 'default'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setJsonMode(false);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
Form
|
||||
</Button>
|
||||
<Button
|
||||
variant={jsonMode ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => {
|
||||
// Build current config as JSON for the editor
|
||||
const currentConfig: Record<string, unknown> = {};
|
||||
if (serverType !== 'stdio') {
|
||||
currentConfig.type = serverType;
|
||||
if (url) currentConfig.url = url;
|
||||
} else {
|
||||
currentConfig.command = command;
|
||||
}
|
||||
const argsArr = args.split('\n').map(s => s.trim()).filter(Boolean);
|
||||
if (argsArr.length > 0) currentConfig.args = argsArr;
|
||||
try {
|
||||
const envParsed = JSON.parse(envText);
|
||||
if (Object.keys(envParsed).length > 0) currentConfig.env = envParsed;
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
const headersParsed = JSON.parse(headersText);
|
||||
if (Object.keys(headersParsed).length > 0) currentConfig.headers = headersParsed;
|
||||
} catch { /* ignore */ }
|
||||
setJsonText(JSON.stringify(currentConfig, null, 2));
|
||||
setJsonMode(true);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
<HugeiconsIcon icon={CodeIcon} className="h-3.5 w-3.5" />
|
||||
JSON
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{jsonMode ? (
|
||||
<div className="space-y-2">
|
||||
<Label>Server Configuration (JSON)</Label>
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => {
|
||||
setJsonText(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
className="font-mono text-sm min-h-[250px]"
|
||||
placeholder='{"command": "npx", "args": ["-y", "@server/name"]}'
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>Server Type</Label>
|
||||
<Tabs
|
||||
value={serverType}
|
||||
onValueChange={(v) => {
|
||||
setServerType(v as ServerType);
|
||||
setError(null);
|
||||
}}
|
||||
>
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="stdio" className="flex-1 gap-1.5">
|
||||
<HugeiconsIcon icon={ServerStack01Icon} className="h-3.5 w-3.5" />
|
||||
stdio
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="sse" className="flex-1 gap-1.5">
|
||||
<HugeiconsIcon icon={Wifi01Icon} className="h-3.5 w-3.5" />
|
||||
SSE
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="http" className="flex-1 gap-1.5">
|
||||
<HugeiconsIcon icon={GlobeIcon} className="h-3.5 w-3.5" />
|
||||
HTTP
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
{serverType === 'stdio' ? (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-command">Command</Label>
|
||||
<Input
|
||||
id="server-command"
|
||||
value={command}
|
||||
onChange={(e) => {
|
||||
setCommand(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder="npx -y @modelcontextprotocol/server-name"
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-args">Arguments (one per line)</Label>
|
||||
<Textarea
|
||||
id="server-args"
|
||||
value={args}
|
||||
onChange={(e) => setArgs(e.target.value)}
|
||||
placeholder={"--flag\nvalue"}
|
||||
className="font-mono text-sm min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-url">URL</Label>
|
||||
<Input
|
||||
id="server-url"
|
||||
value={url}
|
||||
onChange={(e) => {
|
||||
setUrl(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder={
|
||||
serverType === 'sse'
|
||||
? 'http://localhost:3001/sse'
|
||||
: 'http://localhost:3001'
|
||||
}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-headers">Headers (JSON)</Label>
|
||||
<Textarea
|
||||
id="server-headers"
|
||||
value={headersText}
|
||||
onChange={(e) => {
|
||||
setHeadersText(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder='{"Authorization": "Bearer ..."}'
|
||||
className="font-mono text-sm min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="server-env">Environment Variables (JSON)</Label>
|
||||
<Textarea
|
||||
id="server-env"
|
||||
value={envText}
|
||||
onChange={(e) => {
|
||||
setEnvText(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
placeholder='{"API_KEY": "..."}'
|
||||
className="font-mono text-sm min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave}>
|
||||
{isEditing ? 'Save Changes' : 'Add Server'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import type { IconSvgElement } from "@hugeicons/react";
|
||||
import { Delete02Icon, PencilIcon, ServerStack01Icon, Wifi01Icon, GlobeIcon } from "@hugeicons/core-free-icons";
|
||||
import type { MCPServer } from '@/types';
|
||||
|
||||
interface McpServerListProps {
|
||||
servers: Record<string, MCPServer>;
|
||||
onEdit: (name: string, server: MCPServer) => void;
|
||||
onDelete: (name: string) => void;
|
||||
}
|
||||
|
||||
function getServerTypeInfo(server: MCPServer) {
|
||||
const type = server.type || 'stdio';
|
||||
switch (type) {
|
||||
case 'sse':
|
||||
return { label: 'SSE', icon: Wifi01Icon, color: 'text-blue-500' };
|
||||
case 'http':
|
||||
return { label: 'HTTP', icon: GlobeIcon, color: 'text-green-500' };
|
||||
default:
|
||||
return { label: 'stdio', icon: ServerStack01Icon, color: 'text-muted-foreground' };
|
||||
}
|
||||
}
|
||||
|
||||
export function McpServerList({ servers, onEdit, onDelete }: McpServerListProps) {
|
||||
const entries = Object.entries(servers);
|
||||
|
||||
if (entries.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<HugeiconsIcon icon={ServerStack01Icon} className="h-10 w-10 mb-3 opacity-50" />
|
||||
<p className="text-sm">No MCP servers configured</p>
|
||||
<p className="text-xs mt-1">
|
||||
Add an MCP server to extend Claude's capabilities
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{entries.map(([name, server]) => {
|
||||
const typeInfo = getServerTypeInfo(server);
|
||||
return (
|
||||
<Card key={name}>
|
||||
<CardHeader className="flex flex-row items-start justify-between space-y-0 pb-2">
|
||||
<div className="flex-1 min-w-0 mr-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<HugeiconsIcon icon={typeInfo.icon} className={`h-4 w-4 shrink-0 ${typeInfo.color}`} />
|
||||
<CardTitle className="text-sm font-medium">{name}</CardTitle>
|
||||
<Badge variant="outline" className="text-xs shrink-0">
|
||||
{typeInfo.label}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
Configured
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="text-xs mt-1 font-mono">
|
||||
{server.url
|
||||
? server.url
|
||||
: `${server.command} ${server.args?.join(' ') || ''}`}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="flex gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onEdit(name, server)}
|
||||
>
|
||||
<HugeiconsIcon icon={PencilIcon} className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={() => onDelete(name)}
|
||||
>
|
||||
<HugeiconsIcon icon={Delete02Icon} className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{(server.env && Object.keys(server.env).length > 0) ||
|
||||
(server.args && server.args.length > 0) ? (
|
||||
<CardContent className="pt-0">
|
||||
{server.args && server.args.length > 0 && (
|
||||
<div className="mb-2">
|
||||
<p className="text-xs text-muted-foreground mb-1">Arguments:</p>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{server.args.map((arg, i) => (
|
||||
<Badge key={i} variant="outline" className="text-xs font-mono">
|
||||
{arg}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{server.env && Object.keys(server.env).length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mb-1">Environment:</p>
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{Object.keys(server.env).map((key) => (
|
||||
<Badge key={key} variant="outline" className="text-xs font-mono">
|
||||
{key}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from '@/components/ui/card';
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { ArrowDown01Icon, ArrowUp01Icon, ZapIcon } from "@hugeicons/core-free-icons";
|
||||
|
||||
export interface SkillInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
source: 'global' | 'project' | 'plugin';
|
||||
content: string;
|
||||
filePath: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
interface PluginCardProps {
|
||||
plugin: SkillInfo;
|
||||
onSelect: (plugin: SkillInfo) => void;
|
||||
}
|
||||
|
||||
export function PluginCard({ plugin, onSelect }: PluginCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const isProject = plugin.source === 'project';
|
||||
const isPlugin = plugin.source === 'plugin';
|
||||
const displayName = isProject
|
||||
? plugin.name.replace('project:', '')
|
||||
: plugin.name;
|
||||
|
||||
return (
|
||||
<Card className="transition-colors hover:bg-accent/50">
|
||||
<CardHeader
|
||||
className="flex flex-row items-start justify-between space-y-0 pb-3 cursor-pointer"
|
||||
onClick={() => onSelect(plugin)}
|
||||
>
|
||||
<div className="flex-1 min-w-0 mr-3">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<HugeiconsIcon icon={ZapIcon} className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<CardTitle className="text-sm font-medium truncate">
|
||||
/{displayName}
|
||||
</CardTitle>
|
||||
<Badge
|
||||
variant={isProject ? 'secondary' : 'outline'}
|
||||
className="text-xs shrink-0"
|
||||
>
|
||||
{isPlugin ? 'Plugin' : isProject ? 'Project' : 'Global'}
|
||||
</Badge>
|
||||
</div>
|
||||
<CardDescription className="text-xs line-clamp-2">
|
||||
{plugin.description}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpanded(!expanded);
|
||||
}}
|
||||
>
|
||||
{expanded ? (
|
||||
<HugeiconsIcon icon={ArrowUp01Icon} className="h-4 w-4" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={ArrowDown01Icon} className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
{expanded && (
|
||||
<CardContent className="pt-0">
|
||||
<div className="rounded-md bg-muted p-3 max-h-60 overflow-auto">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
|
||||
{plugin.content}
|
||||
</pre>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-2 font-mono truncate">
|
||||
{plugin.filePath}
|
||||
</p>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Cancel01Icon, ZapIcon } from "@hugeicons/core-free-icons";
|
||||
import type { SkillInfo } from './PluginCard';
|
||||
|
||||
interface PluginDetailProps {
|
||||
plugin: SkillInfo;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PluginDetail({ plugin, onClose }: PluginDetailProps) {
|
||||
const isProject = plugin.source === 'project';
|
||||
const displayName = isProject
|
||||
? plugin.name.replace('project:', '')
|
||||
: plugin.name;
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-4 bg-card">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<HugeiconsIcon icon={ZapIcon} className="h-4 w-4 text-muted-foreground" />
|
||||
<h3 className="text-lg font-semibold">/{displayName}</h3>
|
||||
<Badge variant={isProject ? 'secondary' : 'outline'}>
|
||||
{isProject ? 'Project' : 'Global'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{plugin.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" onClick={onClose}>
|
||||
<HugeiconsIcon icon={Cancel01Icon} className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator className="my-3" />
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Source</p>
|
||||
<p className="text-xs text-muted-foreground font-mono break-all">
|
||||
{plugin.filePath}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-1">Type</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isProject ? 'Project-level skill' : 'User-level skill'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium mb-2">Content</p>
|
||||
<div className="rounded-md bg-muted p-3 max-h-80 overflow-auto">
|
||||
<pre className="text-xs font-mono whitespace-pre-wrap break-words">
|
||||
{plugin.content}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { PluginCard, type SkillInfo } from './PluginCard';
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Search01Icon, GlobeIcon, FolderOpenIcon, Plug01Icon } from "@hugeicons/core-free-icons";
|
||||
|
||||
interface PluginListProps {
|
||||
plugins: SkillInfo[];
|
||||
onSelect: (plugin: SkillInfo) => void;
|
||||
}
|
||||
|
||||
export function PluginList({ plugins, onSelect }: PluginListProps) {
|
||||
const [search, setSearch] = useState('');
|
||||
const [sourceFilter, setSourceFilter] = useState<'all' | 'global' | 'project' | 'plugin'>('all');
|
||||
|
||||
const filtered = plugins.filter((p) => {
|
||||
const matchesSearch =
|
||||
p.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
p.description.toLowerCase().includes(search.toLowerCase());
|
||||
if (sourceFilter === 'all') return matchesSearch;
|
||||
return matchesSearch && p.source === sourceFilter;
|
||||
});
|
||||
|
||||
const globalCount = plugins.filter((p) => p.source === 'global').length;
|
||||
const projectCount = plugins.filter((p) => p.source === 'project').length;
|
||||
const pluginCount = plugins.filter((p) => p.source === 'plugin').length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="relative">
|
||||
<HugeiconsIcon icon={Search01Icon} className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search skills by name..."
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={sourceFilter}
|
||||
onValueChange={(v) => setSourceFilter(v as 'all' | 'global' | 'project' | 'plugin')}
|
||||
>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">
|
||||
All ({plugins.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="global" className="gap-1.5">
|
||||
<HugeiconsIcon icon={GlobeIcon} className="h-3.5 w-3.5" />
|
||||
Global ({globalCount})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="project" className="gap-1.5">
|
||||
<HugeiconsIcon icon={FolderOpenIcon} className="h-3.5 w-3.5" />
|
||||
Project ({projectCount})
|
||||
</TabsTrigger>
|
||||
{pluginCount > 0 && (
|
||||
<TabsTrigger value="plugin" className="gap-1.5">
|
||||
<HugeiconsIcon icon={Plug01Icon} className="h-3.5 w-3.5" />
|
||||
Plugin ({pluginCount})
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value={sourceFilter} className="mt-4">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
|
||||
<p className="text-sm">
|
||||
{plugins.length === 0
|
||||
? 'No skills found'
|
||||
: 'No matching skills'}
|
||||
</p>
|
||||
<p className="text-xs mt-1">
|
||||
{plugins.length === 0
|
||||
? 'Add .md files to ~/.claude/commands/ or .claude/commands/ to create skills'
|
||||
: 'Try adjusting your search or filter'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{filtered.map((plugin) => (
|
||||
<PluginCard
|
||||
key={plugin.name}
|
||||
plugin={plugin}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user