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:
7418andClaude Opus 4.6 committed 2026-02-06 21:04:05 +08:00
1 parent 6776cc6297
commit ff1fb38735
149 files changed
+31117 -122

No files matched your search

+54
View File
@@ -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' } },
);
}
}
+164
View File
@@ -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 });
}
}
+69
View File
@@ -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 });
}
}
+32
View File
@@ -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 });
}
}
+18
View File
@@ -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 });
}
}
+42
View File
@@ -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 }
);
}
}
+39
View File
@@ -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 }
);
}
}
+38
View File
@@ -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 }
);
}
}
+5
View File
@@ -0,0 +1,5 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({ status: 'ok' });
}
+113
View File
@@ -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 }
);
}
}
+59
View File
@@ -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 }
);
}
}
+105
View File
@@ -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 }
);
}
}
+99
View File
@@ -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 }
);
}
}
+60
View File
@@ -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 }
);
}
}
+129
View File
@@ -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 }
);
}
}
+246
View File
@@ -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 }
);
}
}
+59
View File
@@ -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 }
);
}
}
+46
View File
@@ -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 }
);
}
}