mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-10 14:59:46 +00:00
Implement comprehensive API key management functionality including generation, validation, and CRUD operations. Changes: - Add API key database schema and operations (create, validate, delete, toggle) - Generating a commit message will now work properly with claude sdk and cursor cli and return a suggested commit message - Implement crypto-based key generation with 'ck_' prefix - Add session ID tracking in claude-sdk.js and cursor-cli.js - Update database layer with API key validation and last_used tracking - Support multi-user API key management with user association This enables secure programmatic access to the agent service
560 lines
17 KiB
JavaScript
560 lines
17 KiB
JavaScript
import express from 'express';
|
|
import { spawn } from 'child_process';
|
|
import path from 'path';
|
|
import os from 'os';
|
|
import { promises as fs } from 'fs';
|
|
import crypto from 'crypto';
|
|
import { apiKeysDb, githubTokensDb } from '../database/db.js';
|
|
import { addProjectManually } from '../projects.js';
|
|
import { queryClaudeSDK } from '../claude-sdk.js';
|
|
import { spawnCursor } from '../cursor-cli.js';
|
|
|
|
const router = express.Router();
|
|
|
|
// Middleware to validate API key for external requests
|
|
const validateExternalApiKey = (req, res, next) => {
|
|
const apiKey = req.headers['x-api-key'] || req.query.apiKey;
|
|
|
|
if (!apiKey) {
|
|
return res.status(401).json({ error: 'API key required' });
|
|
}
|
|
|
|
const user = apiKeysDb.validateApiKey(apiKey);
|
|
|
|
if (!user) {
|
|
return res.status(401).json({ error: 'Invalid or inactive API key' });
|
|
}
|
|
|
|
req.user = user;
|
|
next();
|
|
};
|
|
|
|
/**
|
|
* Get the remote URL of a git repository
|
|
* @param {string} repoPath - Path to the git repository
|
|
* @returns {Promise<string>} - Remote URL of the repository
|
|
*/
|
|
async function getGitRemoteUrl(repoPath) {
|
|
return new Promise((resolve, reject) => {
|
|
const gitProcess = spawn('git', ['config', '--get', 'remote.origin.url'], {
|
|
cwd: repoPath,
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
gitProcess.stdout.on('data', (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
|
|
gitProcess.stderr.on('data', (data) => {
|
|
stderr += data.toString();
|
|
});
|
|
|
|
gitProcess.on('close', (code) => {
|
|
if (code === 0) {
|
|
resolve(stdout.trim());
|
|
} else {
|
|
reject(new Error(`Failed to get git remote: ${stderr}`));
|
|
}
|
|
});
|
|
|
|
gitProcess.on('error', (error) => {
|
|
reject(new Error(`Failed to execute git: ${error.message}`));
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Normalize GitHub URLs for comparison
|
|
* @param {string} url - GitHub URL
|
|
* @returns {string} - Normalized URL
|
|
*/
|
|
function normalizeGitHubUrl(url) {
|
|
// Remove .git suffix
|
|
let normalized = url.replace(/\.git$/, '');
|
|
// Convert SSH to HTTPS format for comparison
|
|
normalized = normalized.replace(/^git@github\.com:/, 'https://github.com/');
|
|
// Remove trailing slash
|
|
normalized = normalized.replace(/\/$/, '');
|
|
return normalized.toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Clone a GitHub repository to a directory
|
|
* @param {string} githubUrl - GitHub repository URL
|
|
* @param {string} githubToken - Optional GitHub token for private repos
|
|
* @param {string} projectPath - Path for cloning the repository
|
|
* @returns {Promise<string>} - Path to the cloned repository
|
|
*/
|
|
async function cloneGitHubRepo(githubUrl, githubToken = null, projectPath) {
|
|
return new Promise(async (resolve, reject) => {
|
|
try {
|
|
// Validate GitHub URL
|
|
if (!githubUrl || !githubUrl.includes('github.com')) {
|
|
throw new Error('Invalid GitHub URL');
|
|
}
|
|
|
|
const cloneDir = path.resolve(projectPath);
|
|
|
|
// Check if directory already exists
|
|
try {
|
|
await fs.access(cloneDir);
|
|
// Directory exists - check if it's a git repo with the same URL
|
|
try {
|
|
const existingUrl = await getGitRemoteUrl(cloneDir);
|
|
const normalizedExisting = normalizeGitHubUrl(existingUrl);
|
|
const normalizedRequested = normalizeGitHubUrl(githubUrl);
|
|
|
|
if (normalizedExisting === normalizedRequested) {
|
|
console.log('✅ Repository already exists at path with correct URL');
|
|
return resolve(cloneDir);
|
|
} else {
|
|
throw new Error(`Directory ${cloneDir} already exists with a different repository (${existingUrl}). Expected: ${githubUrl}`);
|
|
}
|
|
} catch (gitError) {
|
|
throw new Error(`Directory ${cloneDir} already exists but is not a valid git repository or git command failed`);
|
|
}
|
|
} catch (accessError) {
|
|
// Directory doesn't exist - proceed with clone
|
|
}
|
|
|
|
// Ensure parent directory exists
|
|
await fs.mkdir(path.dirname(cloneDir), { recursive: true });
|
|
|
|
// Prepare the git clone URL with authentication if token is provided
|
|
let cloneUrl = githubUrl;
|
|
if (githubToken) {
|
|
// Convert HTTPS URL to authenticated URL
|
|
// Example: https://github.com/user/repo -> https://token@github.com/user/repo
|
|
cloneUrl = githubUrl.replace('https://github.com', `https://${githubToken}@github.com`);
|
|
}
|
|
|
|
console.log('🔄 Cloning repository:', githubUrl);
|
|
console.log('📁 Destination:', cloneDir);
|
|
|
|
// Execute git clone
|
|
const gitProcess = spawn('git', ['clone', '--depth', '1', cloneUrl, cloneDir], {
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
|
|
gitProcess.stdout.on('data', (data) => {
|
|
stdout += data.toString();
|
|
});
|
|
|
|
gitProcess.stderr.on('data', (data) => {
|
|
stderr += data.toString();
|
|
console.log('Git stderr:', data.toString());
|
|
});
|
|
|
|
gitProcess.on('close', (code) => {
|
|
if (code === 0) {
|
|
console.log('✅ Repository cloned successfully');
|
|
resolve(cloneDir);
|
|
} else {
|
|
console.error('❌ Git clone failed:', stderr);
|
|
reject(new Error(`Git clone failed: ${stderr}`));
|
|
}
|
|
});
|
|
|
|
gitProcess.on('error', (error) => {
|
|
reject(new Error(`Failed to execute git: ${error.message}`));
|
|
});
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Clean up a temporary project directory and its Claude session
|
|
* @param {string} projectPath - Path to the project directory
|
|
* @param {string} sessionId - Session ID to clean up
|
|
*/
|
|
async function cleanupProject(projectPath, sessionId = null) {
|
|
try {
|
|
// Only clean up projects in the external-projects directory
|
|
if (!projectPath.includes('.claude/external-projects')) {
|
|
console.warn('⚠️ Refusing to clean up non-external project:', projectPath);
|
|
return;
|
|
}
|
|
|
|
console.log('🧹 Cleaning up project:', projectPath);
|
|
await fs.rm(projectPath, { recursive: true, force: true });
|
|
console.log('✅ Project cleaned up');
|
|
|
|
// Also clean up the Claude session directory if sessionId provided
|
|
if (sessionId) {
|
|
try {
|
|
const sessionPath = path.join(os.homedir(), '.claude', 'sessions', sessionId);
|
|
console.log('🧹 Cleaning up session directory:', sessionPath);
|
|
await fs.rm(sessionPath, { recursive: true, force: true });
|
|
console.log('✅ Session directory cleaned up');
|
|
} catch (error) {
|
|
console.error('⚠️ Failed to clean up session directory:', error.message);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Failed to clean up project:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SSE Stream Writer - Adapts SDK/CLI output to Server-Sent Events
|
|
*/
|
|
class SSEStreamWriter {
|
|
constructor(res) {
|
|
this.res = res;
|
|
this.sessionId = null;
|
|
}
|
|
|
|
send(data) {
|
|
if (this.res.writableEnded) {
|
|
return;
|
|
}
|
|
|
|
// Format as SSE
|
|
this.res.write(`data: ${JSON.stringify(data)}\n\n`);
|
|
}
|
|
|
|
end() {
|
|
if (!this.res.writableEnded) {
|
|
this.res.write('data: {"type":"done"}\n\n');
|
|
this.res.end();
|
|
}
|
|
}
|
|
|
|
setSessionId(sessionId) {
|
|
this.sessionId = sessionId;
|
|
}
|
|
|
|
getSessionId() {
|
|
return this.sessionId;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Non-streaming response collector
|
|
*/
|
|
class ResponseCollector {
|
|
constructor() {
|
|
this.messages = [];
|
|
this.sessionId = null;
|
|
}
|
|
|
|
send(data) {
|
|
// Store ALL messages for now - we'll filter when returning
|
|
this.messages.push(data);
|
|
|
|
// Extract sessionId if present
|
|
if (typeof data === 'string') {
|
|
try {
|
|
const parsed = JSON.parse(data);
|
|
if (parsed.sessionId) {
|
|
this.sessionId = parsed.sessionId;
|
|
}
|
|
} catch (e) {
|
|
// Not JSON, ignore
|
|
}
|
|
} else if (data && data.sessionId) {
|
|
this.sessionId = data.sessionId;
|
|
}
|
|
}
|
|
|
|
end() {
|
|
// Do nothing - we'll collect all messages
|
|
}
|
|
|
|
setSessionId(sessionId) {
|
|
this.sessionId = sessionId;
|
|
}
|
|
|
|
getSessionId() {
|
|
return this.sessionId;
|
|
}
|
|
|
|
getMessages() {
|
|
return this.messages;
|
|
}
|
|
|
|
/**
|
|
* Get filtered assistant messages only
|
|
*/
|
|
getAssistantMessages() {
|
|
const assistantMessages = [];
|
|
|
|
for (const msg of this.messages) {
|
|
// Skip initial status message
|
|
if (msg && msg.type === 'status') {
|
|
continue;
|
|
}
|
|
|
|
// Handle JSON strings
|
|
if (typeof msg === 'string') {
|
|
try {
|
|
const parsed = JSON.parse(msg);
|
|
// Only include claude-response messages with assistant type
|
|
if (parsed.type === 'claude-response' && parsed.data && parsed.data.type === 'assistant') {
|
|
assistantMessages.push(parsed.data);
|
|
}
|
|
} catch (e) {
|
|
// Not JSON, skip
|
|
}
|
|
}
|
|
}
|
|
|
|
return assistantMessages;
|
|
}
|
|
|
|
/**
|
|
* Calculate total tokens from all messages
|
|
*/
|
|
getTotalTokens() {
|
|
let totalInput = 0;
|
|
let totalOutput = 0;
|
|
let totalCacheRead = 0;
|
|
let totalCacheCreation = 0;
|
|
|
|
for (const msg of this.messages) {
|
|
let data = msg;
|
|
|
|
// Parse if string
|
|
if (typeof msg === 'string') {
|
|
try {
|
|
data = JSON.parse(msg);
|
|
} catch (e) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Extract usage from claude-response messages
|
|
if (data && data.type === 'claude-response' && data.data) {
|
|
const msgData = data.data;
|
|
if (msgData.message && msgData.message.usage) {
|
|
const usage = msgData.message.usage;
|
|
totalInput += usage.input_tokens || 0;
|
|
totalOutput += usage.output_tokens || 0;
|
|
totalCacheRead += usage.cache_read_input_tokens || 0;
|
|
totalCacheCreation += usage.cache_creation_input_tokens || 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
inputTokens: totalInput,
|
|
outputTokens: totalOutput,
|
|
cacheReadTokens: totalCacheRead,
|
|
cacheCreationTokens: totalCacheCreation,
|
|
totalTokens: totalInput + totalOutput + totalCacheRead + totalCacheCreation
|
|
};
|
|
}
|
|
}
|
|
|
|
// ===============================
|
|
// External API Endpoint
|
|
// ===============================
|
|
|
|
/**
|
|
* POST /api/agent
|
|
*
|
|
* Trigger an AI agent (Claude or Cursor) to work on a project
|
|
*
|
|
* Body:
|
|
* - githubUrl: string (conditionally required) - GitHub repository URL to clone
|
|
* - projectPath: string (conditionally required) - Path to existing project or where to clone
|
|
* - message: string (required) - Message to send to the AI agent
|
|
* - provider: string (optional) - 'claude' or 'cursor' (default: 'claude')
|
|
* - stream: boolean (optional) - Whether to stream responses (default: true)
|
|
* - model: string (optional) - Model to use (for Cursor)
|
|
* - cleanup: boolean (optional) - Whether to cleanup project after completion (default: true)
|
|
* - githubToken: string (optional) - GitHub token for private repos (overrides stored token)
|
|
*
|
|
* Note: Either githubUrl OR projectPath must be provided. If both are provided, githubUrl will be cloned to projectPath.
|
|
*/
|
|
router.post('/', validateExternalApiKey, async (req, res) => {
|
|
const { githubUrl, projectPath, message, provider = 'claude', model, githubToken } = req.body;
|
|
|
|
// Parse stream and cleanup as booleans (handle string "true"/"false" from curl)
|
|
const stream = req.body.stream === undefined ? true : (req.body.stream === true || req.body.stream === 'true');
|
|
const cleanup = req.body.cleanup === undefined ? true : (req.body.cleanup === true || req.body.cleanup === 'true');
|
|
|
|
// Validate inputs
|
|
if (!githubUrl && !projectPath) {
|
|
return res.status(400).json({ error: 'Either githubUrl or projectPath is required' });
|
|
}
|
|
|
|
if (!message || !message.trim()) {
|
|
return res.status(400).json({ error: 'message is required' });
|
|
}
|
|
|
|
if (!['claude', 'cursor'].includes(provider)) {
|
|
return res.status(400).json({ error: 'provider must be "claude" or "cursor"' });
|
|
}
|
|
|
|
let finalProjectPath = null;
|
|
let writer = null;
|
|
|
|
try {
|
|
// Determine the final project path
|
|
if (githubUrl) {
|
|
// Clone repository (to projectPath if provided, otherwise generate path)
|
|
const tokenToUse = githubToken || githubTokensDb.getActiveGithubToken(req.user.id);
|
|
|
|
let targetPath;
|
|
if (projectPath) {
|
|
targetPath = projectPath;
|
|
} else {
|
|
// Generate a unique path for cloning
|
|
const repoHash = crypto.createHash('md5').update(githubUrl + Date.now()).digest('hex');
|
|
targetPath = path.join(os.homedir(), '.claude', 'external-projects', repoHash);
|
|
}
|
|
|
|
finalProjectPath = await cloneGitHubRepo(githubUrl.trim(), tokenToUse, targetPath);
|
|
} else {
|
|
// Use existing project path
|
|
finalProjectPath = path.resolve(projectPath);
|
|
|
|
// Verify the path exists
|
|
try {
|
|
await fs.access(finalProjectPath);
|
|
} catch (error) {
|
|
throw new Error(`Project path does not exist: ${finalProjectPath}`);
|
|
}
|
|
}
|
|
|
|
// Register the project (or use existing registration)
|
|
let project;
|
|
try {
|
|
project = await addProjectManually(finalProjectPath);
|
|
console.log('📦 Project registered:', project);
|
|
} catch (error) {
|
|
// If project already exists, that's fine - continue with the existing registration
|
|
if (error.message && error.message.includes('Project already configured')) {
|
|
console.log('📦 Using existing project registration for:', finalProjectPath);
|
|
project = { path: finalProjectPath };
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Set up writer based on streaming mode
|
|
if (stream) {
|
|
// Set up SSE headers for streaming
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
res.setHeader('X-Accel-Buffering', 'no'); // Disable nginx buffering
|
|
|
|
writer = new SSEStreamWriter(res);
|
|
|
|
// Send initial status
|
|
writer.send({
|
|
type: 'status',
|
|
message: githubUrl ? 'Repository cloned and session started' : 'Session started',
|
|
projectPath: finalProjectPath
|
|
});
|
|
} else {
|
|
// Non-streaming mode: collect messages
|
|
writer = new ResponseCollector();
|
|
|
|
// Collect initial status message
|
|
writer.send({
|
|
type: 'status',
|
|
message: githubUrl ? 'Repository cloned and session started' : 'Session started',
|
|
projectPath: finalProjectPath
|
|
});
|
|
}
|
|
|
|
// Start the appropriate session
|
|
if (provider === 'claude') {
|
|
console.log('🤖 Starting Claude SDK session');
|
|
|
|
await queryClaudeSDK(message.trim(), {
|
|
projectPath: finalProjectPath,
|
|
cwd: finalProjectPath,
|
|
sessionId: null, // New session
|
|
permissionMode: 'bypassPermissions' // Bypass all permissions for API calls
|
|
}, writer);
|
|
|
|
} else if (provider === 'cursor') {
|
|
console.log('🖱️ Starting Cursor CLI session');
|
|
|
|
await spawnCursor(message.trim(), {
|
|
projectPath: finalProjectPath,
|
|
cwd: finalProjectPath,
|
|
sessionId: null, // New session
|
|
model: model || undefined,
|
|
skipPermissions: true // Bypass permissions for Cursor
|
|
}, writer);
|
|
}
|
|
|
|
// Handle response based on streaming mode
|
|
if (stream) {
|
|
// Streaming mode: end the SSE stream
|
|
writer.end();
|
|
} else {
|
|
// Non-streaming mode: send filtered messages and token summary as JSON
|
|
const assistantMessages = writer.getAssistantMessages();
|
|
const tokenSummary = writer.getTotalTokens();
|
|
|
|
res.json({
|
|
success: true,
|
|
sessionId: writer.getSessionId(),
|
|
messages: assistantMessages,
|
|
tokens: tokenSummary,
|
|
projectPath: finalProjectPath
|
|
});
|
|
}
|
|
|
|
// Clean up if requested
|
|
if (cleanup && githubUrl) {
|
|
// Only cleanup if we cloned a repo (not for existing project paths)
|
|
const sessionIdForCleanup = writer.getSessionId();
|
|
setTimeout(() => {
|
|
cleanupProject(finalProjectPath, sessionIdForCleanup);
|
|
}, 5000);
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error('❌ External session error:', error);
|
|
|
|
// Clean up on error
|
|
if (finalProjectPath && cleanup && githubUrl) {
|
|
const sessionIdForCleanup = writer ? writer.getSessionId() : null;
|
|
cleanupProject(finalProjectPath, sessionIdForCleanup);
|
|
}
|
|
|
|
if (stream) {
|
|
// For streaming, send error event and stop
|
|
if (!writer) {
|
|
// Set up SSE headers if not already done
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
res.setHeader('X-Accel-Buffering', 'no');
|
|
writer = new SSEStreamWriter(res);
|
|
}
|
|
|
|
if (!res.writableEnded) {
|
|
writer.send({
|
|
type: 'error',
|
|
error: error.message,
|
|
message: `Failed: ${error.message}`
|
|
});
|
|
writer.end();
|
|
}
|
|
} else if (!res.headersSent) {
|
|
res.status(500).json({
|
|
success: false,
|
|
error: error.message
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
export default router;
|