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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user