Files
claudecodeui/server/routes/agent.js
simos eda89ef147 feat(api): add API for one-shot prompt generatio, key authentication system and git commit message generation
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
2025-10-30 20:59:25 +00:00

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;