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,54 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { resolvePendingPermission } from '@/lib/permission-registry';
|
||||
import type { PermissionResponseRequest } from '@/types';
|
||||
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: PermissionResponseRequest = await request.json();
|
||||
const { permissionRequestId, decision } = body;
|
||||
|
||||
if (!permissionRequestId || !decision) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'permissionRequestId and decision are required' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
let result: PermissionResult;
|
||||
if (decision.behavior === 'allow') {
|
||||
result = {
|
||||
behavior: 'allow',
|
||||
updatedPermissions: decision.updatedPermissions as unknown as PermissionUpdate[],
|
||||
};
|
||||
} else {
|
||||
result = {
|
||||
behavior: 'deny',
|
||||
message: decision.message || 'User denied permission',
|
||||
};
|
||||
}
|
||||
|
||||
const found = resolvePendingPermission(permissionRequestId, result);
|
||||
|
||||
if (!found) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: 'Permission request not found or already resolved' }),
|
||||
{ status: 404, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({ success: true }),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return new Response(
|
||||
JSON.stringify({ error: message }),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { streamClaude } from '@/lib/claude-client';
|
||||
import { addMessage, getSession, updateSessionTitle, updateSdkSessionId, getSetting } from '@/lib/db';
|
||||
import type { SendMessageRequest, SSEEvent, TokenUsage } from '@/types';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: SendMessageRequest = await request.json();
|
||||
const { session_id, content, model, mode } = body;
|
||||
|
||||
if (!session_id || !content) {
|
||||
return new Response(JSON.stringify({ error: 'session_id and content are required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const session = getSession(session_id);
|
||||
if (!session) {
|
||||
return new Response(JSON.stringify({ error: 'Session not found' }), {
|
||||
status: 404,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Save user message
|
||||
addMessage(session_id, 'user', content);
|
||||
|
||||
// Auto-generate title from first message if still default
|
||||
if (session.title === 'New Chat') {
|
||||
const title = content.slice(0, 50) + (content.length > 50 ? '...' : '');
|
||||
updateSessionTitle(session_id, title);
|
||||
}
|
||||
|
||||
// Determine model: request override > session model > default setting
|
||||
const effectiveModel = model || session.model || getSetting('default_model') || undefined;
|
||||
|
||||
// Determine permission mode from chat mode: code → acceptEdits, plan → plan, ask → default (no tools)
|
||||
const effectiveMode = mode || session.mode || 'code';
|
||||
let permissionMode: string;
|
||||
let systemPromptOverride: string | undefined;
|
||||
switch (effectiveMode) {
|
||||
case 'plan':
|
||||
permissionMode = 'plan';
|
||||
break;
|
||||
case 'ask':
|
||||
permissionMode = 'default';
|
||||
systemPromptOverride = (session.system_prompt || '') +
|
||||
'\n\nYou are in Ask mode. Answer questions and provide information only. Do not use any tools, do not read or write files, do not execute commands. Only respond with text.';
|
||||
break;
|
||||
default: // 'code'
|
||||
permissionMode = 'acceptEdits';
|
||||
break;
|
||||
}
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
// Handle client disconnect
|
||||
request.signal.addEventListener('abort', () => {
|
||||
abortController.abort();
|
||||
});
|
||||
|
||||
// Stream Claude response, using SDK session ID for resume if available
|
||||
const stream = streamClaude({
|
||||
prompt: content,
|
||||
sessionId: session_id,
|
||||
sdkSessionId: session.sdk_session_id || undefined,
|
||||
model: effectiveModel,
|
||||
systemPrompt: systemPromptOverride || session.system_prompt || undefined,
|
||||
workingDirectory: session.working_directory || undefined,
|
||||
abortController,
|
||||
permissionMode,
|
||||
});
|
||||
|
||||
// Tee the stream: one for client, one for collecting the response
|
||||
const [streamForClient, streamForCollect] = stream.tee();
|
||||
|
||||
// Save assistant message in background
|
||||
collectStreamResponse(streamForCollect, session_id);
|
||||
|
||||
return new Response(streamForClient, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function collectStreamResponse(stream: ReadableStream<string>, sessionId: string) {
|
||||
const reader = stream.getReader();
|
||||
let fullText = '';
|
||||
let tokenUsage: TokenUsage | null = null;
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const lines = value.split('\n');
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const event: SSEEvent = JSON.parse(line.slice(6));
|
||||
if (event.type === 'permission_request' || event.type === 'tool_output') {
|
||||
// Skip permission_request and tool_output events - not saved as message content
|
||||
} else if (event.type === 'text') {
|
||||
fullText += event.data;
|
||||
} else if (event.type === 'status') {
|
||||
// Capture SDK session_id from init event and persist it
|
||||
try {
|
||||
const statusData = JSON.parse(event.data);
|
||||
if (statusData.session_id) {
|
||||
updateSdkSessionId(sessionId, statusData.session_id);
|
||||
}
|
||||
} catch {
|
||||
// skip malformed status data
|
||||
}
|
||||
} else if (event.type === 'result') {
|
||||
try {
|
||||
const resultData = JSON.parse(event.data);
|
||||
if (resultData.usage) {
|
||||
tokenUsage = resultData.usage;
|
||||
}
|
||||
// Also capture session_id from result if we missed it from init
|
||||
if (resultData.session_id) {
|
||||
updateSdkSessionId(sessionId, resultData.session_id);
|
||||
}
|
||||
} catch {
|
||||
// skip malformed result data
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fullText.trim()) {
|
||||
addMessage(
|
||||
sessionId,
|
||||
'assistant',
|
||||
fullText.trim(),
|
||||
tokenUsage ? JSON.stringify(tokenUsage) : null,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Stream reading error - best effort save
|
||||
if (fullText.trim()) {
|
||||
addMessage(sessionId, 'assistant', fullText.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getMessages, getSession } from '@/lib/db';
|
||||
import type { MessagesResponse } from '@/types';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = getSession(id);
|
||||
if (!session) {
|
||||
return Response.json({ error: 'Session not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const messages = getMessages(id);
|
||||
const response: MessagesResponse = { messages };
|
||||
return Response.json(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch messages';
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { deleteSession, getSession, updateSessionWorkingDirectory, updateSessionTitle, updateSessionMode } from '@/lib/db';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = getSession(id);
|
||||
if (!session) {
|
||||
return Response.json({ error: 'Session not found' }, { status: 404 });
|
||||
}
|
||||
return Response.json({ session });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to get session';
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = getSession(id);
|
||||
if (!session) {
|
||||
return Response.json({ error: 'Session not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
|
||||
if (body.working_directory) {
|
||||
updateSessionWorkingDirectory(id, body.working_directory);
|
||||
}
|
||||
if (body.title) {
|
||||
updateSessionTitle(id, body.title);
|
||||
}
|
||||
if (body.mode) {
|
||||
updateSessionMode(id, body.mode);
|
||||
}
|
||||
|
||||
const updated = getSession(id);
|
||||
return Response.json({ session: updated });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to update session';
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const session = getSession(id);
|
||||
if (!session) {
|
||||
return Response.json({ error: 'Session not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
deleteSession(id);
|
||||
return Response.json({ success: true });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to delete session';
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getAllSessions, createSession } from '@/lib/db';
|
||||
import type { CreateSessionRequest, SessionsResponse, SessionResponse } from '@/types';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const sessions = getAllSessions();
|
||||
const response: SessionsResponse = { sessions };
|
||||
return Response.json(response);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch sessions';
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: CreateSessionRequest = await request.json();
|
||||
const session = createSession(
|
||||
body.title,
|
||||
body.model,
|
||||
body.system_prompt,
|
||||
body.working_directory,
|
||||
body.mode,
|
||||
);
|
||||
const response: SessionResponse = { session };
|
||||
return Response.json(response, { status: 201 });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to create session';
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('claude', ['--version'], {
|
||||
timeout: 5000,
|
||||
env: { ...process.env, PATH: process.env.PATH },
|
||||
});
|
||||
const version = stdout.trim();
|
||||
return NextResponse.json({ connected: true, version });
|
||||
} catch {
|
||||
return NextResponse.json({ connected: false, version: null });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import type { ErrorResponse } from '@/types';
|
||||
|
||||
// List only directories for folder browsing (no safety restriction since user is choosing where to work)
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const dir = searchParams.get('dir') || os.homedir();
|
||||
|
||||
const resolvedDir = path.resolve(dir);
|
||||
|
||||
if (!fs.existsSync(resolvedDir)) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: 'Directory does not exist' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(resolvedDir, { withFileTypes: true });
|
||||
const directories = entries
|
||||
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
||||
.map((e) => ({
|
||||
name: e.name,
|
||||
path: path.join(resolvedDir, e.name),
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return NextResponse.json({
|
||||
current: resolvedDir,
|
||||
parent: path.dirname(resolvedDir) !== resolvedDir ? path.dirname(resolvedDir) : null,
|
||||
directories,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: 'Cannot read directory' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { readFilePreview, isPathSafe } from '@/lib/files';
|
||||
import type { FilePreviewResponse, ErrorResponse } from '@/types';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const filePath = searchParams.get('path');
|
||||
const maxLines = parseInt(searchParams.get('maxLines') || '200', 10);
|
||||
|
||||
if (!filePath) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: 'Missing path parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const path = require('path');
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
|
||||
// Basic safety: the file must exist under some reasonable base
|
||||
// We check it resolves to an absolute path and is not a traversal attempt
|
||||
const dir = path.dirname(resolvedPath);
|
||||
if (!isPathSafe(dir, resolvedPath)) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: 'Invalid file path' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const preview = readFilePreview(resolvedPath, Math.min(maxLines, 1000));
|
||||
return NextResponse.json<FilePreviewResponse>({ preview });
|
||||
} catch (error) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to read file' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { scanDirectory, isPathSafe } from '@/lib/files';
|
||||
import type { FileTreeResponse, ErrorResponse } from '@/types';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const dir = searchParams.get('dir');
|
||||
const depth = parseInt(searchParams.get('depth') || '3', 10);
|
||||
|
||||
if (!dir) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: 'Missing dir parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Safety: resolve absolute path and validate
|
||||
const path = require('path');
|
||||
const resolvedDir = path.resolve(dir);
|
||||
|
||||
// Only allow scanning within the provided directory
|
||||
if (!isPathSafe(resolvedDir, resolvedDir)) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: 'Invalid directory path' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const tree = scanDirectory(resolvedDir, Math.min(depth, 5));
|
||||
return NextResponse.json<FileTreeResponse>({ tree, root: resolvedDir });
|
||||
} catch (error) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to scan directory' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({ status: 'ok' });
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import type { PluginInfo, ErrorResponse, SuccessResponse } from '@/types';
|
||||
|
||||
function getClaudeDir(): string {
|
||||
return path.join(os.homedir(), '.claude');
|
||||
}
|
||||
|
||||
function getSettingsPath(): string {
|
||||
return path.join(getClaudeDir(), 'settings.json');
|
||||
}
|
||||
|
||||
function readSettings(): Record<string, unknown> {
|
||||
const settingsPath = getSettingsPath();
|
||||
if (!fs.existsSync(settingsPath)) return {};
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeSettings(settings: Record<string, unknown>): void {
|
||||
const settingsPath = getSettingsPath();
|
||||
const dir = path.dirname(settingsPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
): Promise<NextResponse<{ plugin: PluginInfo } | ErrorResponse>> {
|
||||
const { id } = await params;
|
||||
const pluginName = decodeURIComponent(id);
|
||||
|
||||
// Check in commands directory
|
||||
const commandsDir = path.join(getClaudeDir(), 'commands');
|
||||
const filePath = path.join(commandsDir, `${pluginName}.md`);
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const firstLine = content.split('\n')[0]?.trim() || '';
|
||||
return NextResponse.json({
|
||||
plugin: {
|
||||
name: pluginName,
|
||||
description: firstLine.startsWith('#')
|
||||
? firstLine.replace(/^#+\s*/, '')
|
||||
: `Skill: /${pluginName}`,
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Check in settings customCommands
|
||||
const settings = readSettings();
|
||||
const customCommands = (settings.customCommands || {}) as Record<
|
||||
string,
|
||||
{ description?: string; enabled?: boolean }
|
||||
>;
|
||||
if (customCommands[pluginName]) {
|
||||
const cmd = customCommands[pluginName];
|
||||
return NextResponse.json({
|
||||
plugin: {
|
||||
name: pluginName,
|
||||
description: cmd.description || `Custom command: /${pluginName}`,
|
||||
enabled: cmd.enabled !== false,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'Plugin not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
): Promise<NextResponse<SuccessResponse | ErrorResponse>> {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const pluginName = decodeURIComponent(id);
|
||||
const body = await request.json();
|
||||
const { enabled } = body as { enabled: boolean };
|
||||
|
||||
const settings = readSettings();
|
||||
if (!settings.customCommands) {
|
||||
settings.customCommands = {};
|
||||
}
|
||||
|
||||
const customCommands = settings.customCommands as Record<
|
||||
string,
|
||||
{ description?: string; enabled?: boolean }
|
||||
>;
|
||||
|
||||
if (customCommands[pluginName]) {
|
||||
customCommands[pluginName].enabled = enabled;
|
||||
} else {
|
||||
customCommands[pluginName] = { enabled };
|
||||
}
|
||||
|
||||
writeSettings(settings);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to update plugin' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import type { MCPServerConfig, ErrorResponse, SuccessResponse } from '@/types';
|
||||
|
||||
function getSettingsPath(): string {
|
||||
return path.join(os.homedir(), '.claude', 'settings.json');
|
||||
}
|
||||
|
||||
function readSettings(): Record<string, unknown> {
|
||||
const settingsPath = getSettingsPath();
|
||||
if (!fs.existsSync(settingsPath)) return {};
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeSettings(settings: Record<string, unknown>): void {
|
||||
const settingsPath = getSettingsPath();
|
||||
const dir = path.dirname(settingsPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
): Promise<NextResponse<SuccessResponse | ErrorResponse>> {
|
||||
try {
|
||||
const { name } = await params;
|
||||
const serverName = decodeURIComponent(name);
|
||||
|
||||
const settings = readSettings();
|
||||
const mcpServers = (settings.mcpServers || {}) as Record<string, MCPServerConfig>;
|
||||
|
||||
if (!mcpServers[serverName]) {
|
||||
return NextResponse.json(
|
||||
{ error: `MCP server "${serverName}" not found` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
delete mcpServers[serverName];
|
||||
settings.mcpServers = mcpServers;
|
||||
writeSettings(settings);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to delete MCP server' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
MCPConfigResponse,
|
||||
ErrorResponse,
|
||||
SuccessResponse,
|
||||
} from '@/types';
|
||||
|
||||
function getSettingsPath(): string {
|
||||
return path.join(os.homedir(), '.claude', 'settings.json');
|
||||
}
|
||||
|
||||
function readSettings(): Record<string, unknown> {
|
||||
const settingsPath = getSettingsPath();
|
||||
if (!fs.existsSync(settingsPath)) return {};
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeSettings(settings: Record<string, unknown>): void {
|
||||
const settingsPath = getSettingsPath();
|
||||
const dir = path.dirname(settingsPath);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export async function GET(): Promise<NextResponse<MCPConfigResponse | ErrorResponse>> {
|
||||
try {
|
||||
const settings = readSettings();
|
||||
const mcpServers = (settings.mcpServers || {}) as Record<string, MCPServerConfig>;
|
||||
return NextResponse.json({ mcpServers });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to read MCP config' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest
|
||||
): Promise<NextResponse<SuccessResponse | ErrorResponse>> {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { mcpServers } = body as { mcpServers: Record<string, MCPServerConfig> };
|
||||
|
||||
const settings = readSettings();
|
||||
settings.mcpServers = mcpServers;
|
||||
writeSettings(settings);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to update MCP config' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest
|
||||
): Promise<NextResponse<SuccessResponse | ErrorResponse>> {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, server } = body as { name: string; server: MCPServerConfig };
|
||||
|
||||
if (!name || !server || !server.command) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Name and server command are required' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const settings = readSettings();
|
||||
if (!settings.mcpServers) {
|
||||
settings.mcpServers = {};
|
||||
}
|
||||
|
||||
const mcpServers = settings.mcpServers as Record<string, MCPServerConfig>;
|
||||
if (mcpServers[name]) {
|
||||
return NextResponse.json(
|
||||
{ error: `MCP server "${name}" already exists` },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
mcpServers[name] = server;
|
||||
writeSettings(settings);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to add MCP server' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import type { ErrorResponse } from '@/types';
|
||||
|
||||
export interface SkillInfo {
|
||||
name: string;
|
||||
description: string;
|
||||
source: 'global' | 'project';
|
||||
content: string;
|
||||
filePath: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface SkillsResponse {
|
||||
plugins: SkillInfo[];
|
||||
}
|
||||
|
||||
function getClaudeDir(): string {
|
||||
return path.join(os.homedir(), '.claude');
|
||||
}
|
||||
|
||||
function discoverSkills(): SkillInfo[] {
|
||||
const claudeDir = getClaudeDir();
|
||||
const skills: SkillInfo[] = [];
|
||||
|
||||
// Scan for .md skill files in global commands directory
|
||||
const globalCommandsDir = path.join(claudeDir, 'commands');
|
||||
if (fs.existsSync(globalCommandsDir)) {
|
||||
try {
|
||||
const files = fs.readdirSync(globalCommandsDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
const name = file.replace(/\.md$/, '');
|
||||
const filePath = path.join(globalCommandsDir, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const firstLine = content.split('\n')[0]?.trim() || '';
|
||||
const description = firstLine.startsWith('#')
|
||||
? firstLine.replace(/^#+\s*/, '')
|
||||
: `Skill: /${name}`;
|
||||
skills.push({
|
||||
name,
|
||||
description,
|
||||
source: 'global',
|
||||
content,
|
||||
filePath,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
// Scan project-level .claude/commands
|
||||
const projectCommandsDir = path.join(process.cwd(), '.claude', 'commands');
|
||||
if (fs.existsSync(projectCommandsDir)) {
|
||||
try {
|
||||
const files = fs.readdirSync(projectCommandsDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith('.md')) {
|
||||
const name = file.replace(/\.md$/, '');
|
||||
const filePath = path.join(projectCommandsDir, file);
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const firstLine = content.split('\n')[0]?.trim() || '';
|
||||
const description = firstLine.startsWith('#')
|
||||
? firstLine.replace(/^#+\s*/, '')
|
||||
: `Project skill: /${name}`;
|
||||
skills.push({
|
||||
name: `project:${name}`,
|
||||
description,
|
||||
source: 'project',
|
||||
content,
|
||||
filePath,
|
||||
enabled: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore read errors
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
export async function GET(): Promise<NextResponse<SkillsResponse | ErrorResponse>> {
|
||||
try {
|
||||
const plugins = discoverSkills();
|
||||
return NextResponse.json({ plugins });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to load plugins' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
const SETTINGS_PATH = path.join(os.homedir(), ".claude", "settings.json");
|
||||
|
||||
function readSettingsFile(): Record<string, unknown> {
|
||||
try {
|
||||
if (fs.existsSync(SETTINGS_PATH)) {
|
||||
const content = fs.readFileSync(SETTINGS_PATH, "utf-8");
|
||||
return JSON.parse(content);
|
||||
}
|
||||
} catch {
|
||||
// Return empty object if file doesn't exist or is invalid
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function writeSettingsFile(data: Record<string, unknown>): void {
|
||||
const dir = path.dirname(SETTINGS_PATH);
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(data, null, 2), "utf-8");
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const settings = readSettingsFile();
|
||||
return NextResponse.json({ settings });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to read settings" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { settings } = body;
|
||||
|
||||
if (!settings || typeof settings !== "object") {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid settings data" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
writeSettingsFile(settings);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to save settings" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
function getGlobalCommandsDir(): string {
|
||||
return path.join(os.homedir(), ".claude", "commands");
|
||||
}
|
||||
|
||||
function getProjectCommandsDir(): string {
|
||||
return path.join(process.cwd(), ".claude", "commands");
|
||||
}
|
||||
|
||||
function getInstalledSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".agents", "skills");
|
||||
}
|
||||
|
||||
function findSkillFile(
|
||||
name: string
|
||||
): { filePath: string; source: "global" | "project" | "installed" } | null {
|
||||
// Check project first, then global, then installed (~/.agents/skills/)
|
||||
const projectPath = path.join(getProjectCommandsDir(), `${name}.md`);
|
||||
if (fs.existsSync(projectPath)) {
|
||||
return { filePath: projectPath, source: "project" };
|
||||
}
|
||||
const globalPath = path.join(getGlobalCommandsDir(), `${name}.md`);
|
||||
if (fs.existsSync(globalPath)) {
|
||||
return { filePath: globalPath, source: "global" };
|
||||
}
|
||||
// Installed skills: ~/.agents/skills/{name}/SKILL.md
|
||||
const installedPath = path.join(getInstalledSkillsDir(), name, "SKILL.md");
|
||||
if (fs.existsSync(installedPath)) {
|
||||
return { filePath: installedPath, source: "installed" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
try {
|
||||
const { name } = await params;
|
||||
const found = findSkillFile(name);
|
||||
if (!found) {
|
||||
return NextResponse.json({ error: "Skill not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(found.filePath, "utf-8");
|
||||
const firstLine = content.split("\n")[0]?.trim() || "";
|
||||
const description = firstLine.startsWith("#")
|
||||
? firstLine.replace(/^#+\s*/, "")
|
||||
: firstLine || `Skill: /${name}`;
|
||||
|
||||
return NextResponse.json({
|
||||
skill: {
|
||||
name,
|
||||
description,
|
||||
content,
|
||||
source: found.source,
|
||||
filePath: found.filePath,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Failed to read skill" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
try {
|
||||
const { name } = await params;
|
||||
const body = await request.json();
|
||||
const { content } = body as { content: string };
|
||||
|
||||
const found = findSkillFile(name);
|
||||
if (!found) {
|
||||
return NextResponse.json({ error: "Skill not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
fs.writeFileSync(found.filePath, content ?? "", "utf-8");
|
||||
|
||||
const firstLine = (content ?? "").split("\n")[0]?.trim() || "";
|
||||
const description = firstLine.startsWith("#")
|
||||
? firstLine.replace(/^#+\s*/, "")
|
||||
: firstLine || `Skill: /${name}`;
|
||||
|
||||
return NextResponse.json({
|
||||
skill: {
|
||||
name,
|
||||
description,
|
||||
content: content ?? "",
|
||||
source: found.source,
|
||||
filePath: found.filePath,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Failed to update skill" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: Request,
|
||||
{ params }: { params: Promise<{ name: string }> }
|
||||
) {
|
||||
try {
|
||||
const { name } = await params;
|
||||
const found = findSkillFile(name);
|
||||
if (!found) {
|
||||
return NextResponse.json({ error: "Skill not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
fs.unlinkSync(found.filePath);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Failed to delete skill" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
interface SkillFile {
|
||||
name: string;
|
||||
description: string;
|
||||
content: string;
|
||||
source: "global" | "project" | "plugin" | "installed";
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
function getGlobalCommandsDir(): string {
|
||||
return path.join(os.homedir(), ".claude", "commands");
|
||||
}
|
||||
|
||||
function getProjectCommandsDir(): string {
|
||||
return path.join(process.cwd(), ".claude", "commands");
|
||||
}
|
||||
|
||||
function getPluginCommandsDirs(): string[] {
|
||||
const dirs: string[] = [];
|
||||
const marketplacesDir = path.join(os.homedir(), ".claude", "plugins", "marketplaces");
|
||||
if (!fs.existsSync(marketplacesDir)) return dirs;
|
||||
|
||||
try {
|
||||
// Scan marketplaces -> each marketplace -> plugins -> each plugin -> commands
|
||||
const marketplaces = fs.readdirSync(marketplacesDir);
|
||||
for (const marketplace of marketplaces) {
|
||||
const pluginsDir = path.join(marketplacesDir, marketplace, "plugins");
|
||||
if (!fs.existsSync(pluginsDir)) continue;
|
||||
const plugins = fs.readdirSync(pluginsDir);
|
||||
for (const plugin of plugins) {
|
||||
const commandsDir = path.join(pluginsDir, plugin, "commands");
|
||||
if (fs.existsSync(commandsDir)) {
|
||||
dirs.push(commandsDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return dirs;
|
||||
}
|
||||
|
||||
function getInstalledSkillsDir(): string {
|
||||
return path.join(os.homedir(), ".agents", "skills");
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse YAML front matter from SKILL.md content.
|
||||
* Extracts `name` and `description` fields from the --- delimited block.
|
||||
*/
|
||||
function parseSkillFrontMatter(content: string): { name?: string; description?: string } {
|
||||
// Extract front matter between --- delimiters
|
||||
const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
|
||||
if (!fmMatch) return {};
|
||||
|
||||
const frontMatter = fmMatch[1];
|
||||
const lines = frontMatter.split(/\r?\n/);
|
||||
const result: { name?: string; description?: string } = {};
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
// Match name: value
|
||||
const nameMatch = line.match(/^name:\s*(.+)/);
|
||||
if (nameMatch) {
|
||||
result.name = nameMatch[1].trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match description: | (multi-line YAML block scalar) — check FIRST
|
||||
if (/^description:\s*\|/.test(line)) {
|
||||
const descLines: string[] = [];
|
||||
for (let j = i + 1; j < lines.length; j++) {
|
||||
if (/^\s+/.test(lines[j])) {
|
||||
descLines.push(lines[j].trim());
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (descLines.length > 0) {
|
||||
result.description = descLines.filter(Boolean).join(" ");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Match description: value (single-line)
|
||||
const descMatch = line.match(/^description:\s+(.+)/);
|
||||
if (descMatch) {
|
||||
result.description = descMatch[1].trim();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan ~/.agents/skills/ for installed skills (npx skills add).
|
||||
* Each skill is a directory containing a SKILL.md with YAML front matter.
|
||||
*/
|
||||
function scanInstalledSkills(): SkillFile[] {
|
||||
const skills: SkillFile[] = [];
|
||||
const dir = getInstalledSkillsDir();
|
||||
if (!fs.existsSync(dir)) return skills;
|
||||
|
||||
try {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.name.startsWith(".")) continue;
|
||||
const skillMdPath = path.join(dir, entry.name, "SKILL.md");
|
||||
if (!fs.existsSync(skillMdPath)) continue;
|
||||
|
||||
const content = fs.readFileSync(skillMdPath, "utf-8");
|
||||
const meta = parseSkillFrontMatter(content);
|
||||
const name = meta.name || entry.name;
|
||||
const description = meta.description || `Installed skill: /${name}`;
|
||||
|
||||
skills.push({
|
||||
name,
|
||||
description,
|
||||
content,
|
||||
source: "installed",
|
||||
filePath: skillMdPath,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// ignore read errors
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
function scanDirectory(
|
||||
dir: string,
|
||||
source: "global" | "project" | "plugin"
|
||||
): SkillFile[] {
|
||||
const skills: SkillFile[] = [];
|
||||
if (!fs.existsSync(dir)) return skills;
|
||||
|
||||
try {
|
||||
const files = fs.readdirSync(dir);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".md")) continue;
|
||||
const name = file.replace(/\.md$/, "");
|
||||
const filePath = path.join(dir, file);
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const firstLine = content.split("\n")[0]?.trim() || "";
|
||||
const description = firstLine.startsWith("#")
|
||||
? firstLine.replace(/^#+\s*/, "")
|
||||
: firstLine || `Skill: /${name}`;
|
||||
skills.push({ name, description, content, source, filePath });
|
||||
}
|
||||
} catch {
|
||||
// ignore read errors
|
||||
}
|
||||
return skills;
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const globalSkills = scanDirectory(getGlobalCommandsDir(), "global");
|
||||
const projectSkills = scanDirectory(getProjectCommandsDir(), "project");
|
||||
const installedSkills = scanInstalledSkills();
|
||||
|
||||
// Scan installed plugin skills
|
||||
const pluginSkills: SkillFile[] = [];
|
||||
for (const dir of getPluginCommandsDirs()) {
|
||||
pluginSkills.push(...scanDirectory(dir, "plugin"));
|
||||
}
|
||||
|
||||
return NextResponse.json({ skills: [...globalSkills, ...projectSkills, ...installedSkills, ...pluginSkills] });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Failed to load skills" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, content, scope } = body as {
|
||||
name: string;
|
||||
content: string;
|
||||
scope: "global" | "project";
|
||||
};
|
||||
|
||||
if (!name || typeof name !== "string") {
|
||||
return NextResponse.json(
|
||||
{ error: "Skill name is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Sanitize name: only allow alphanumeric, hyphens, underscores
|
||||
const safeName = name.replace(/[^a-zA-Z0-9_-]/g, "-");
|
||||
if (!safeName) {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid skill name" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const dir =
|
||||
scope === "project" ? getProjectCommandsDir() : getGlobalCommandsDir();
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
const filePath = path.join(dir, `${safeName}.md`);
|
||||
if (fs.existsSync(filePath)) {
|
||||
return NextResponse.json(
|
||||
{ error: "A skill with this name already exists" },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(filePath, content || "", "utf-8");
|
||||
|
||||
const firstLine = (content || "").split("\n")[0]?.trim() || "";
|
||||
const description = firstLine.startsWith("#")
|
||||
? firstLine.replace(/^#+\s*/, "")
|
||||
: firstLine || `Skill: /${safeName}`;
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
skill: {
|
||||
name: safeName,
|
||||
description,
|
||||
content: content || "",
|
||||
source: scope || "global",
|
||||
filePath,
|
||||
},
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : "Failed to create skill" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { updateTask, deleteTask, getTask } from '@/lib/db';
|
||||
import type { TaskResponse, ErrorResponse, UpdateTaskRequest } from '@/types';
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, context: RouteContext) {
|
||||
const { id } = await context.params;
|
||||
|
||||
try {
|
||||
const body: UpdateTaskRequest = await request.json();
|
||||
const existing = getTask(id);
|
||||
|
||||
if (!existing) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: 'Task not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const updated = updateTask(id, body);
|
||||
if (!updated) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: 'Failed to update task' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json<TaskResponse>({ task: updated });
|
||||
} catch (error) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to update task' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(_request: NextRequest, context: RouteContext) {
|
||||
const { id } = await context.params;
|
||||
|
||||
try {
|
||||
const deleted = deleteTask(id);
|
||||
if (!deleted) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: 'Task not found' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to delete task' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getTasksBySession, createTask } from '@/lib/db';
|
||||
import type { TasksResponse, TaskResponse, ErrorResponse, CreateTaskRequest } from '@/types';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const sessionId = searchParams.get('session_id');
|
||||
|
||||
if (!sessionId) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: 'Missing session_id parameter' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const tasks = getTasksBySession(sessionId);
|
||||
return NextResponse.json<TasksResponse>({ tasks });
|
||||
} catch (error) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to get tasks' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body: CreateTaskRequest = await request.json();
|
||||
|
||||
if (!body.session_id || !body.title) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: 'Missing session_id or title' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const task = createTask(body.session_id, body.title, body.description);
|
||||
return NextResponse.json<TaskResponse>({ task }, { status: 201 });
|
||||
} catch (error) {
|
||||
return NextResponse.json<ErrorResponse>(
|
||||
{ error: error instanceof Error ? error.message : 'Failed to create task' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState, use } from 'react';
|
||||
import type { Message, MessagesResponse, ChatSession } from '@/types';
|
||||
import { ChatView } from '@/components/chat/ChatView';
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Loading02Icon } from "@hugeicons/core-free-icons";
|
||||
import { usePanel } from '@/hooks/usePanel';
|
||||
|
||||
interface ChatSessionPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function ChatSessionPage({ params }: ChatSessionPageProps) {
|
||||
const { id } = use(params);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [sessionTitle, setSessionTitle] = useState<string>('');
|
||||
const [sessionModel, setSessionModel] = useState<string>('');
|
||||
const [sessionMode, setSessionMode] = useState<string>('');
|
||||
const { setWorkingDirectory, setSessionId, setSessionTitle: setPanelSessionTitle, setPanelOpen } = usePanel();
|
||||
|
||||
// Load session info and set working directory
|
||||
useEffect(() => {
|
||||
async function loadSession() {
|
||||
try {
|
||||
const res = await fetch(`/api/chat/sessions/${id}`);
|
||||
if (res.ok) {
|
||||
const data: { session: ChatSession } = await res.json();
|
||||
if (data.session.working_directory) {
|
||||
setWorkingDirectory(data.session.working_directory);
|
||||
}
|
||||
setSessionId(id);
|
||||
setPanelOpen(true);
|
||||
const title = data.session.title || 'New Conversation';
|
||||
setSessionTitle(title);
|
||||
setPanelSessionTitle(title);
|
||||
setSessionModel(data.session.model || '');
|
||||
setSessionMode(data.session.mode || 'code');
|
||||
}
|
||||
} catch {
|
||||
// Session info load failed - panel will still work without directory
|
||||
}
|
||||
}
|
||||
|
||||
loadSession();
|
||||
}, [id, setWorkingDirectory, setSessionId, setPanelSessionTitle, setPanelOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
async function loadMessages() {
|
||||
try {
|
||||
const res = await fetch(`/api/chat/sessions/${id}/messages`);
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) {
|
||||
setError('Session not found');
|
||||
return;
|
||||
}
|
||||
throw new Error('Failed to load messages');
|
||||
}
|
||||
const data: MessagesResponse = await res.json();
|
||||
setMessages(data.messages);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load messages');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadMessages();
|
||||
}, [id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center space-y-2">
|
||||
<p className="text-destructive font-medium">{error}</p>
|
||||
<a href="/chat" className="text-sm text-muted-foreground hover:underline">
|
||||
Start a new chat
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
{/* Chat title bar */}
|
||||
{sessionTitle && (
|
||||
<div
|
||||
className="flex items-center justify-center px-4 py-2"
|
||||
style={{ WebkitAppRegion: 'drag' } as React.CSSProperties}
|
||||
>
|
||||
<h2 className="text-sm font-medium text-foreground/80 truncate max-w-md">
|
||||
{sessionTitle}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
<ChatView sessionId={id} initialMessages={messages} modelName={sessionModel} initialMode={sessionMode} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { Message, SSEEvent, SessionResponse, TokenUsage, PermissionRequestEvent } from '@/types';
|
||||
import { MessageList } from '@/components/chat/MessageList';
|
||||
import { MessageInput } from '@/components/chat/MessageInput';
|
||||
import { usePanel } from '@/hooks/usePanel';
|
||||
|
||||
interface ToolUseInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
}
|
||||
|
||||
interface ToolResultInfo {
|
||||
tool_use_id: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export default function NewChatPage() {
|
||||
const router = useRouter();
|
||||
const { setWorkingDirectory, setPanelOpen, setPendingApprovalSessionId } = usePanel();
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [streamingContent, setStreamingContent] = useState('');
|
||||
const [isStreaming, setIsStreaming] = useState(false);
|
||||
const [toolUses, setToolUses] = useState<ToolUseInfo[]>([]);
|
||||
const [toolResults, setToolResults] = useState<ToolResultInfo[]>([]);
|
||||
const [statusText, setStatusText] = useState<string | undefined>();
|
||||
const [workingDir, setWorkingDir] = useState('');
|
||||
const [mode, setMode] = useState('code');
|
||||
const [currentModel, setCurrentModel] = useState('sonnet');
|
||||
const [pendingPermission, setPendingPermission] = useState<PermissionRequestEvent | null>(null);
|
||||
const [permissionResolved, setPermissionResolved] = useState<'allow' | 'deny' | null>(null);
|
||||
const [streamingToolOutput, setStreamingToolOutput] = useState('');
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleWorkingDirectoryChange = useCallback((dir: string) => {
|
||||
setWorkingDir(dir);
|
||||
setWorkingDirectory(dir);
|
||||
setPanelOpen(true);
|
||||
}, [setWorkingDirectory, setPanelOpen]);
|
||||
|
||||
const stopStreaming = useCallback(() => {
|
||||
abortControllerRef.current?.abort();
|
||||
abortControllerRef.current = null;
|
||||
}, []);
|
||||
|
||||
const handlePermissionResponse = useCallback(async (decision: 'allow' | 'allow_session' | 'deny') => {
|
||||
if (!pendingPermission) return;
|
||||
|
||||
const body: { permissionRequestId: string; decision: { behavior: 'allow'; updatedPermissions?: unknown[] } | { behavior: 'deny'; message?: string } } = {
|
||||
permissionRequestId: pendingPermission.permissionRequestId,
|
||||
decision: decision === 'deny'
|
||||
? { behavior: 'deny', message: 'User denied permission' }
|
||||
: {
|
||||
behavior: 'allow',
|
||||
...(decision === 'allow_session' && pendingPermission.suggestions
|
||||
? { updatedPermissions: pendingPermission.suggestions }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
|
||||
setPermissionResolved(decision === 'deny' ? 'deny' : 'allow');
|
||||
setPendingApprovalSessionId('');
|
||||
|
||||
try {
|
||||
await fetch('/api/chat/permission', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setPendingPermission(null);
|
||||
setPermissionResolved(null);
|
||||
}, 1000);
|
||||
}, [pendingPermission, setPendingApprovalSessionId]);
|
||||
|
||||
const sendFirstMessage = useCallback(
|
||||
async (content: string) => {
|
||||
if (isStreaming) return;
|
||||
|
||||
setIsStreaming(true);
|
||||
setStreamingContent('');
|
||||
setToolUses([]);
|
||||
setToolResults([]);
|
||||
setStatusText(undefined);
|
||||
|
||||
const controller = new AbortController();
|
||||
abortControllerRef.current = controller;
|
||||
|
||||
let sessionId = '';
|
||||
|
||||
try {
|
||||
// Create a new session with optional working directory
|
||||
const createBody: Record<string, string> = {
|
||||
title: content.slice(0, 50),
|
||||
mode,
|
||||
};
|
||||
if (workingDir.trim()) {
|
||||
createBody.working_directory = workingDir.trim();
|
||||
}
|
||||
|
||||
const createRes = await fetch('/api/chat/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(createBody),
|
||||
});
|
||||
|
||||
if (!createRes.ok) throw new Error('Failed to create session');
|
||||
|
||||
const { session }: SessionResponse = await createRes.json();
|
||||
sessionId = session.id;
|
||||
|
||||
// Notify ChatListPanel to refresh immediately
|
||||
window.dispatchEvent(new CustomEvent('session-created'));
|
||||
|
||||
// Add user message to UI
|
||||
const userMessage: Message = {
|
||||
id: 'temp-' + Date.now(),
|
||||
session_id: session.id,
|
||||
role: 'user',
|
||||
content,
|
||||
created_at: new Date().toISOString(),
|
||||
token_usage: null,
|
||||
};
|
||||
setMessages([userMessage]);
|
||||
|
||||
// Send the message via streaming API
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: session.id, content, mode, model: currentModel }),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.error || 'Failed to send message');
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('No response stream');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let accumulated = '';
|
||||
let tokenUsage: TokenUsage | null = null;
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
|
||||
try {
|
||||
const event: SSEEvent = JSON.parse(line.slice(6));
|
||||
|
||||
switch (event.type) {
|
||||
case 'text': {
|
||||
accumulated += event.data;
|
||||
setStreamingContent(accumulated);
|
||||
break;
|
||||
}
|
||||
case 'tool_use': {
|
||||
try {
|
||||
const toolData = JSON.parse(event.data);
|
||||
setStreamingToolOutput('');
|
||||
setToolUses((prev) => {
|
||||
if (prev.some((t) => t.id === toolData.id)) return prev;
|
||||
return [...prev, { id: toolData.id, name: toolData.name, input: toolData.input }];
|
||||
});
|
||||
} catch { /* skip */ }
|
||||
break;
|
||||
}
|
||||
case 'tool_result': {
|
||||
try {
|
||||
const resultData = JSON.parse(event.data);
|
||||
setStreamingToolOutput('');
|
||||
setToolResults((prev) => [...prev, { tool_use_id: resultData.tool_use_id, content: resultData.content }]);
|
||||
} catch { /* skip */ }
|
||||
break;
|
||||
}
|
||||
case 'tool_output': {
|
||||
try {
|
||||
const parsed = JSON.parse(event.data);
|
||||
if (parsed._progress) {
|
||||
setStatusText(`Running ${parsed.tool_name}... (${Math.round(parsed.elapsed_time_seconds)}s)`);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — raw stderr output
|
||||
}
|
||||
setStreamingToolOutput((prev) => {
|
||||
const next = prev + (prev ? '\n' : '') + event.data;
|
||||
return next.length > 5000 ? next.slice(-5000) : next;
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'status': {
|
||||
try {
|
||||
const statusData = JSON.parse(event.data);
|
||||
if (statusData.session_id) {
|
||||
setStatusText(`Connected (${statusData.model || 'claude'})`);
|
||||
setTimeout(() => setStatusText(undefined), 2000);
|
||||
} else if (statusData.notification) {
|
||||
setStatusText(statusData.message || statusData.title || undefined);
|
||||
} else {
|
||||
setStatusText(event.data || undefined);
|
||||
}
|
||||
} catch {
|
||||
setStatusText(event.data || undefined);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'result': {
|
||||
try {
|
||||
const resultData = JSON.parse(event.data);
|
||||
if (resultData.usage) tokenUsage = resultData.usage;
|
||||
} catch { /* skip */ }
|
||||
setStatusText(undefined);
|
||||
break;
|
||||
}
|
||||
case 'permission_request': {
|
||||
try {
|
||||
const permData: PermissionRequestEvent = JSON.parse(event.data);
|
||||
setPendingPermission(permData);
|
||||
setPermissionResolved(null);
|
||||
setPendingApprovalSessionId(sessionId);
|
||||
} catch {
|
||||
// skip malformed permission_request data
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'error': {
|
||||
accumulated += '\n\n**Error:** ' + event.data;
|
||||
setStreamingContent(accumulated);
|
||||
break;
|
||||
}
|
||||
case 'done':
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the completed assistant message
|
||||
if (accumulated.trim()) {
|
||||
const assistantMessage: Message = {
|
||||
id: 'temp-assistant-' + Date.now(),
|
||||
session_id: session.id,
|
||||
role: 'assistant',
|
||||
content: accumulated.trim(),
|
||||
created_at: new Date().toISOString(),
|
||||
token_usage: tokenUsage ? JSON.stringify(tokenUsage) : null,
|
||||
};
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
}
|
||||
|
||||
// Navigate to the session page after response is complete
|
||||
router.push(`/chat/${session.id}`);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') {
|
||||
// User stopped - navigate to session if we have one
|
||||
if (sessionId) {
|
||||
router.push(`/chat/${sessionId}`);
|
||||
}
|
||||
} else {
|
||||
const errMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||
const errorMessage: Message = {
|
||||
id: 'temp-error-' + Date.now(),
|
||||
session_id: '',
|
||||
role: 'assistant',
|
||||
content: `**Error:** ${errMsg}`,
|
||||
created_at: new Date().toISOString(),
|
||||
token_usage: null,
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMessage]);
|
||||
}
|
||||
} finally {
|
||||
setIsStreaming(false);
|
||||
setStreamingContent('');
|
||||
setToolUses([]);
|
||||
setToolResults([]);
|
||||
setStreamingToolOutput('');
|
||||
setStatusText(undefined);
|
||||
setPendingPermission(null);
|
||||
setPermissionResolved(null);
|
||||
setPendingApprovalSessionId('');
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[isStreaming, router, workingDir, mode, currentModel, setPendingApprovalSessionId]
|
||||
);
|
||||
|
||||
const handleCommand = useCallback((command: string) => {
|
||||
switch (command) {
|
||||
case '/help': {
|
||||
const helpMessage: Message = {
|
||||
id: 'cmd-' + Date.now(),
|
||||
session_id: '',
|
||||
role: 'assistant',
|
||||
content: `## Available Commands\n\n- **/help** - Show this help message\n- **/clear** - Clear conversation history\n- **/compact** - Compress conversation context\n- **/cost** - Show token usage statistics\n- **/doctor** - Check system health\n- **/init** - Initialize CLAUDE.md\n- **/review** - Start code review\n- **/terminal-setup** - Configure terminal\n\n**Tips:**\n- Type \`@\` to mention files\n- Use Shift+Enter for new line\n- Select a project folder to enable file operations`,
|
||||
created_at: new Date().toISOString(),
|
||||
token_usage: null,
|
||||
};
|
||||
setMessages(prev => [...prev, helpMessage]);
|
||||
break;
|
||||
}
|
||||
case '/clear':
|
||||
setMessages([]);
|
||||
break;
|
||||
case '/cost': {
|
||||
const costMessage: Message = {
|
||||
id: 'cmd-' + Date.now(),
|
||||
session_id: '',
|
||||
role: 'assistant',
|
||||
content: `## Token Usage\n\nToken usage tracking is available after sending messages. Check the token count displayed at the bottom of each assistant response.`,
|
||||
created_at: new Date().toISOString(),
|
||||
token_usage: null,
|
||||
};
|
||||
setMessages(prev => [...prev, costMessage]);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
sendFirstMessage(command);
|
||||
}
|
||||
}, [sendFirstMessage]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<MessageList
|
||||
messages={messages}
|
||||
streamingContent={streamingContent}
|
||||
isStreaming={isStreaming}
|
||||
toolUses={toolUses}
|
||||
toolResults={toolResults}
|
||||
streamingToolOutput={streamingToolOutput}
|
||||
statusText={statusText}
|
||||
pendingPermission={pendingPermission}
|
||||
onPermissionResponse={handlePermissionResponse}
|
||||
permissionResolved={permissionResolved}
|
||||
/>
|
||||
<MessageInput
|
||||
onSend={sendFirstMessage}
|
||||
onCommand={handleCommand}
|
||||
onStop={stopStreaming}
|
||||
disabled={false}
|
||||
isStreaming={isStreaming}
|
||||
modelName={currentModel}
|
||||
onModelChange={setCurrentModel}
|
||||
workingDirectory={workingDir}
|
||||
onWorkingDirectoryChange={handleWorkingDirectoryChange}
|
||||
mode={mode}
|
||||
onModeChange={setMode}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import { Loading02Icon } from "@hugeicons/core-free-icons";
|
||||
import { SkillsManager } from "@/components/skills/SkillsManager";
|
||||
import { McpManager } from "@/components/plugins/McpManager";
|
||||
|
||||
type ExtTab = "skills" | "mcp";
|
||||
|
||||
export default function ExtensionsPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ExtensionsPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function ExtensionsPageInner() {
|
||||
const searchParams = useSearchParams();
|
||||
const initialTab = (searchParams.get("tab") as ExtTab) || "skills";
|
||||
const [tab, setTab] = useState<ExtTab>(initialTab);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b border-border/50 px-6 pt-4 pb-0">
|
||||
<h1 className="text-xl font-semibold mb-3">Extensions</h1>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as ExtTab)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="skills">Skills</TabsTrigger>
|
||||
<TabsTrigger value="mcp">MCP Servers</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{tab === "skills" && <SkillsManager />}
|
||||
{tab === "mcp" && <McpManager />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+187
-14
@@ -1,26 +1,199 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
--radius-2xl: calc(var(--radius) + 8px);
|
||||
--radius-3xl: calc(var(--radius) + 12px);
|
||||
--radius-4xl: calc(var(--radius) + 16px);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
:root {
|
||||
--radius: 0.75rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.147 0.004 49.25);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.147 0.004 49.25);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.147 0.004 49.25);
|
||||
--primary: oklch(0.457 0.24 277.023);
|
||||
--primary-foreground: oklch(0.985 0.001 106.423);
|
||||
--secondary: oklch(0.97 0.001 106.424);
|
||||
--secondary-foreground: oklch(0.216 0.006 56.043);
|
||||
--muted: oklch(0.97 0.001 106.424);
|
||||
--muted-foreground: oklch(0.553 0.013 58.071);
|
||||
--accent: oklch(0.97 0.001 106.424);
|
||||
--accent-foreground: oklch(0.216 0.006 56.043);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.923 0.003 48.717);
|
||||
--input: oklch(0.923 0.003 48.717);
|
||||
--ring: oklch(0.457 0.24 277.023);
|
||||
--chart-1: oklch(0.457 0.24 277.023);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0.001 106.423);
|
||||
--sidebar-foreground: oklch(0.147 0.004 49.25);
|
||||
--sidebar-primary: oklch(0.457 0.24 277.023);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-accent: oklch(0.97 0.001 106.424);
|
||||
--sidebar-accent-foreground: oklch(0.216 0.006 56.043);
|
||||
--sidebar-border: oklch(0.923 0.003 48.717);
|
||||
--sidebar-ring: oklch(0.457 0.24 277.023);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.147 0.004 49.25);
|
||||
--foreground: oklch(0.985 0.001 106.423);
|
||||
--card: oklch(0.216 0.006 56.043);
|
||||
--card-foreground: oklch(0.985 0.001 106.423);
|
||||
--popover: oklch(0.216 0.006 56.043);
|
||||
--popover-foreground: oklch(0.985 0.001 106.423);
|
||||
--primary: oklch(0.585 0.233 277.117);
|
||||
--primary-foreground: oklch(0.985 0.001 106.423);
|
||||
--secondary: oklch(0.268 0.007 34.298);
|
||||
--secondary-foreground: oklch(0.985 0.001 106.423);
|
||||
--muted: oklch(0.268 0.007 34.298);
|
||||
--muted-foreground: oklch(0.709 0.01 56.259);
|
||||
--accent: oklch(0.268 0.007 34.298);
|
||||
--accent-foreground: oklch(0.985 0.001 106.423);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.585 0.233 277.117);
|
||||
--chart-1: oklch(0.585 0.233 277.117);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.216 0.006 56.043);
|
||||
--sidebar-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-primary: oklch(0.585 0.233 277.117);
|
||||
--sidebar-primary-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-accent: oklch(0.268 0.007 34.298);
|
||||
--sidebar-accent-foreground: oklch(0.985 0.001 106.423);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.585 0.233 277.117);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
|
||||
/* Selection color */
|
||||
::selection {
|
||||
background: oklch(0.7 0.15 280 / 30%);
|
||||
}
|
||||
.dark ::selection {
|
||||
background: oklch(0.6 0.2 280 / 35%);
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.7 0 0 / 25%);
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.6 0 0 / 40%);
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb {
|
||||
background: oklch(0.5 0 0 / 30%);
|
||||
}
|
||||
.dark ::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(0.5 0 0 / 50%);
|
||||
}
|
||||
|
||||
/* Prose (markdown) refinements */
|
||||
.prose {
|
||||
--tw-prose-headings: var(--foreground);
|
||||
line-height: 1.7;
|
||||
}
|
||||
.prose p {
|
||||
margin-top: 0.75em;
|
||||
margin-bottom: 0.75em;
|
||||
}
|
||||
.prose h1,
|
||||
.prose h2,
|
||||
.prose h3 {
|
||||
margin-top: 1.25em;
|
||||
margin-bottom: 0.5em;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose ul,
|
||||
.prose ol {
|
||||
margin-top: 0.5em;
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
.prose li {
|
||||
margin-top: 0.25em;
|
||||
margin-bottom: 0.25em;
|
||||
}
|
||||
.prose blockquote {
|
||||
border-left-color: oklch(0.7 0.15 280 / 40%);
|
||||
font-style: normal;
|
||||
}
|
||||
.prose a {
|
||||
color: oklch(0.55 0.2 260);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.dark .prose a {
|
||||
color: oklch(0.7 0.18 260);
|
||||
}
|
||||
.prose hr {
|
||||
border-color: var(--border);
|
||||
}
|
||||
.prose table th {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
+8
-4
@@ -1,6 +1,8 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { ThemeProvider } from "@/components/layout/ThemeProvider";
|
||||
import { AppShell } from "@/components/layout/AppShell";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -13,8 +15,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "CodePilot",
|
||||
description: "A desktop GUI for Claude Code",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,11 +25,13 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<ThemeProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+2
-62
@@ -1,65 +1,5 @@
|
||||
import Image from "next/image";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
redirect("/chat");
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function McpRedirect() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.replace("/extensions?tab=mcp");
|
||||
}, [router]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function PluginsRedirect() {
|
||||
const router = useRouter();
|
||||
useEffect(() => {
|
||||
router.replace("/extensions");
|
||||
}, [router]);
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState, useEffect, useCallback } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
FloppyDiskIcon,
|
||||
ReloadIcon,
|
||||
CodeIcon,
|
||||
SlidersHorizontalIcon,
|
||||
Loading02Icon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
|
||||
interface SettingsData {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// Structured known fields from ~/.claude/settings.json
|
||||
const KNOWN_FIELDS = [
|
||||
{
|
||||
key: "permissions",
|
||||
label: "Permissions",
|
||||
description: "Configure permission settings for Claude CLI",
|
||||
type: "object" as const,
|
||||
},
|
||||
{
|
||||
key: "env",
|
||||
label: "Environment Variables",
|
||||
description: "Environment variables passed to Claude",
|
||||
type: "object" as const,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<SettingsPageInner />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsPageInner() {
|
||||
const [settings, setSettings] = useState<SettingsData>({});
|
||||
const [originalSettings, setOriginalSettings] = useState<SettingsData>({});
|
||||
const [jsonText, setJsonText] = useState("");
|
||||
const [jsonError, setJsonError] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const [pendingSaveAction, setPendingSaveAction] = useState<
|
||||
"form" | "json" | null
|
||||
>(null);
|
||||
|
||||
const fetchSettings = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/api/settings");
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const s = data.settings || {};
|
||||
setSettings(s);
|
||||
setOriginalSettings(s);
|
||||
setJsonText(JSON.stringify(s, null, 2));
|
||||
}
|
||||
} catch {
|
||||
setSettings({});
|
||||
setOriginalSettings({});
|
||||
setJsonText("{}");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
}, [fetchSettings]);
|
||||
|
||||
const hasChanges =
|
||||
JSON.stringify(settings) !== JSON.stringify(originalSettings);
|
||||
|
||||
const handleSave = async (source: "form" | "json") => {
|
||||
let dataToSave: SettingsData;
|
||||
|
||||
if (source === "json") {
|
||||
try {
|
||||
dataToSave = JSON.parse(jsonText);
|
||||
setJsonError("");
|
||||
} catch {
|
||||
setJsonError("Invalid JSON format");
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
dataToSave = settings;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ settings: dataToSave }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setSettings(dataToSave);
|
||||
setOriginalSettings(dataToSave);
|
||||
setJsonText(JSON.stringify(dataToSave, null, 2));
|
||||
setSaveSuccess(true);
|
||||
setTimeout(() => setSaveSuccess(false), 2000);
|
||||
}
|
||||
} catch {
|
||||
// Handle error silently
|
||||
} finally {
|
||||
setSaving(false);
|
||||
setShowConfirmDialog(false);
|
||||
setPendingSaveAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSettings(originalSettings);
|
||||
setJsonText(JSON.stringify(originalSettings, null, 2));
|
||||
setJsonError("");
|
||||
};
|
||||
|
||||
const handleFormatJson = () => {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonText);
|
||||
setJsonText(JSON.stringify(parsed, null, 2));
|
||||
setJsonError("");
|
||||
} catch {
|
||||
setJsonError("Cannot format: invalid JSON");
|
||||
}
|
||||
};
|
||||
|
||||
const confirmSave = (source: "form" | "json") => {
|
||||
setPendingSaveAction(source);
|
||||
setShowConfirmDialog(true);
|
||||
};
|
||||
|
||||
const updateField = (key: string, value: unknown) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="border-b border-border/50 px-6 pt-4 pb-4">
|
||||
<h1 className="text-xl font-semibold">Settings</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage your Claude CLI settings (~/.claude/settings.json)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
<div className="max-w-3xl">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
Loading settings...
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs defaultValue="form">
|
||||
<TabsList className="mb-4">
|
||||
<TabsTrigger value="form" className="gap-2">
|
||||
<HugeiconsIcon icon={SlidersHorizontalIcon} className="h-4 w-4" />
|
||||
Visual Editor
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="json" className="gap-2">
|
||||
<HugeiconsIcon icon={CodeIcon} className="h-4 w-4" />
|
||||
JSON Editor
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="form">
|
||||
<div className="space-y-6">
|
||||
{KNOWN_FIELDS.map((field) => (
|
||||
<div
|
||||
key={field.key}
|
||||
className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm"
|
||||
>
|
||||
<Label className="text-sm font-medium">
|
||||
{field.label}
|
||||
</Label>
|
||||
<p className="mb-2 text-xs text-muted-foreground">
|
||||
{field.description}
|
||||
</p>
|
||||
<Textarea
|
||||
value={
|
||||
typeof settings[field.key] === "object"
|
||||
? JSON.stringify(settings[field.key], null, 2)
|
||||
: String(settings[field.key] ?? "")
|
||||
}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
updateField(field.key, parsed);
|
||||
} catch {
|
||||
updateField(field.key, e.target.value);
|
||||
}
|
||||
}}
|
||||
className="font-mono text-sm"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{Object.entries(settings)
|
||||
.filter(
|
||||
([key]) => !KNOWN_FIELDS.some((f) => f.key === key)
|
||||
)
|
||||
.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="rounded-lg border border-border/50 p-4 transition-shadow hover:shadow-sm"
|
||||
>
|
||||
<Label className="text-sm font-medium">{key}</Label>
|
||||
{typeof value === "boolean" ? (
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<Switch
|
||||
checked={value}
|
||||
onCheckedChange={(checked) =>
|
||||
updateField(key, checked)
|
||||
}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{value ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
) : typeof value === "string" ? (
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) =>
|
||||
updateField(key, e.target.value)
|
||||
}
|
||||
className="mt-2"
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
value={JSON.stringify(value, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
updateField(key, JSON.parse(e.target.value));
|
||||
} catch {
|
||||
updateField(key, e.target.value);
|
||||
}
|
||||
}}
|
||||
className="mt-2 font-mono text-sm"
|
||||
rows={4}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => confirmSave("form")}
|
||||
disabled={!hasChanges || saving}
|
||||
className="gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={FloppyDiskIcon} className="h-4 w-4" />
|
||||
)}
|
||||
{saving ? "Saving..." : "Save Changes"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
disabled={!hasChanges}
|
||||
className="gap-2"
|
||||
>
|
||||
<HugeiconsIcon icon={ReloadIcon} className="h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
{saveSuccess && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
Settings saved successfully
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="json">
|
||||
<div className="space-y-4">
|
||||
<Textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => {
|
||||
setJsonText(e.target.value);
|
||||
setJsonError("");
|
||||
}}
|
||||
className="min-h-[400px] font-mono text-sm"
|
||||
placeholder='{"key": "value"}'
|
||||
/>
|
||||
{jsonError && (
|
||||
<p className="text-sm text-destructive">{jsonError}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
onClick={() => confirmSave("json")}
|
||||
disabled={saving}
|
||||
className="gap-2"
|
||||
>
|
||||
{saving ? (
|
||||
<HugeiconsIcon icon={Loading02Icon} className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<HugeiconsIcon icon={FloppyDiskIcon} className="h-4 w-4" />
|
||||
)}
|
||||
{saving ? "Saving..." : "Save JSON"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleFormatJson}
|
||||
className="gap-2"
|
||||
>
|
||||
<HugeiconsIcon icon={CodeIcon} className="h-4 w-4" />
|
||||
Format
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleReset}
|
||||
className="gap-2"
|
||||
>
|
||||
<HugeiconsIcon icon={ReloadIcon} className="h-4 w-4" />
|
||||
Reset
|
||||
</Button>
|
||||
{saveSuccess && (
|
||||
<span className="text-sm text-green-600 dark:text-green-400">
|
||||
Settings saved successfully
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation dialog */}
|
||||
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Confirm Save</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will overwrite your current ~/.claude/settings.json file. Are
|
||||
you sure you want to continue?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={() => pendingSaveAction && handleSave(pendingSaveAction)}
|
||||
>
|
||||
Save
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user