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
This commit is contained in:
simos
2025-10-30 20:59:25 +00:00
parent 9079326ac5
commit eda89ef147
14 changed files with 2768 additions and 56 deletions

785
public/api-docs.html Normal file
View File

@@ -0,0 +1,785 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Claude Code UI - API Documentation</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<!-- Prism.js for syntax highlighting -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
:root {
--gray-50: #f9fafb;
--gray-100: #f3f4f6;
--gray-200: #e5e7eb;
--gray-600: #4b5563;
--gray-700: #374151;
--gray-800: #1f2937;
--gray-900: #111827;
--primary: #2563eb;
--primary-dark: #1d4ed8;
--green: #10b981;
--red: #ef4444;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
line-height: 1.6;
color: var(--gray-900);
background: var(--gray-50);
margin: 0;
}
header {
background: white;
border-bottom: 1px solid var(--gray-200);
padding: 1.5rem 0;
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.brand-icon {
width: 32px;
height: 32px;
background: var(--primary);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
}
.brand-icon svg {
width: 16px;
height: 16px;
stroke: white;
}
.brand-text h1 {
font-size: 1.25rem;
font-weight: 700;
color: var(--gray-900);
}
.brand-text .subtitle {
font-size: 0.875rem;
color: var(--gray-600);
}
.back-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: var(--primary);
color: white;
text-decoration: none;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 500;
transition: background 0.2s;
}
.back-link:hover {
background: var(--primary-dark);
}
.back-link svg {
width: 16px;
height: 16px;
}
.main-layout {
display: flex;
}
.sidebar {
width: 240px;
background: white;
border-right: 1px solid var(--gray-200);
padding: 2rem 0;
position: sticky;
top: 73px;
height: calc(100vh - 73px);
overflow-y: auto;
flex-shrink: 0;
}
.sidebar-title {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
color: var(--gray-600);
padding: 0 1.5rem;
margin: 1.5rem 0 0.5rem;
}
.sidebar a {
display: block;
padding: 0.625rem 1.5rem;
color: var(--gray-700);
text-decoration: none;
font-size: 0.875rem;
transition: all 0.15s;
border-left: 3px solid transparent;
}
.sidebar a:hover {
background: var(--gray-50);
color: var(--primary);
border-left-color: var(--primary);
}
.content-wrapper {
flex: 1;
display: flex;
flex-direction: column;
min-height: calc(100vh - 73px);
}
.section-row {
display: grid;
grid-template-columns: 1fr 600px;
}
.docs-section {
padding: 3rem 3rem;
background: white;
border-right: 1px solid var(--gray-200);
}
.examples-section {
padding: 3rem 2rem;
background: #0d1117;
color: #e6edf3;
}
.examples-section h4 {
color: #e6edf3;
font-size: 0.875rem;
font-weight: 600;
margin-bottom: 1rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
h2 {
font-size: 2rem;
font-weight: 700;
margin-bottom: 1rem;
color: var(--gray-900);
}
h3 {
font-size: 1.375rem;
font-weight: 600;
margin: 2.5rem 0 1rem;
color: var(--gray-900);
}
h4 {
font-size: 1rem;
font-weight: 600;
margin: 1.5rem 0 0.75rem;
color: var(--gray-700);
}
p {
margin-bottom: 1rem;
color: var(--gray-600);
}
.intro {
background: linear-gradient(135deg, rgba(37, 99, 235, 0.08) 0%, rgba(59, 130, 246, 0.08) 100%);
border: 1px solid rgba(37, 99, 235, 0.2);
border-radius: 8px;
padding: 1.5rem;
margin-bottom: 2rem;
}
.intro p {
color: var(--gray-700);
margin: 0;
}
.endpoint {
margin: 2rem 0;
padding: 1.5rem;
background: var(--gray-50);
border-radius: 8px;
border: 1px solid var(--gray-200);
}
.endpoint-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1rem;
}
.method {
padding: 0.375rem 0.875rem;
border-radius: 6px;
font-weight: 700;
font-size: 0.75rem;
text-transform: uppercase;
}
.method-post {
background: var(--green);
color: white;
}
.endpoint-path {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.9375rem;
font-weight: 600;
}
table {
width: 100%;
border-collapse: collapse;
margin: 1rem 0;
font-size: 0.875rem;
}
th {
text-align: left;
padding: 0.875rem;
background: var(--gray-100);
border: 1px solid var(--gray-200);
font-weight: 600;
color: var(--gray-800);
}
td {
padding: 0.875rem;
border: 1px solid var(--gray-200);
color: var(--gray-700);
}
code {
background: rgba(37, 99, 235, 0.08);
padding: 0.1875rem 0.5rem;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.875em;
color: var(--primary-dark);
}
.api-url {
color: #60a5fa;
}
.badge {
display: inline-block;
padding: 0.1875rem 0.625rem;
border-radius: 12px;
font-size: 0.6875rem;
font-weight: 600;
text-transform: uppercase;
}
.badge-required {
background: var(--red);
color: white;
}
.badge-optional {
background: var(--gray-200);
color: var(--gray-700);
}
.note {
padding: 1.25rem;
background: rgba(37, 99, 235, 0.05);
border-left: 4px solid var(--primary);
border-radius: 8px;
margin: 1rem 0;
font-size: 0.875rem;
}
/* Code tabs in side panel */
.tab-buttons {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tab-button {
padding: 0.5rem 1rem;
background: transparent;
border: 1px solid #30363d;
cursor: pointer;
font-size: 0.8125rem;
font-weight: 500;
color: #7d8590;
border-radius: 6px;
transition: all 0.2s;
}
.tab-button:hover {
color: #e6edf3;
border-color: #58a6ff;
}
.tab-button.active {
color: #e6edf3;
background: #1f6feb;
border-color: #1f6feb;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
pre[class*="language-"] {
margin: 0 0 1.5rem 0;
border-radius: 6px;
font-size: 0.8125rem;
}
.example-block {
margin-bottom: 2rem;
}
@media (max-width: 1400px) {
.section-row {
grid-template-columns: 1fr 500px;
}
}
@media (max-width: 1200px) {
.section-row {
grid-template-columns: 1fr;
}
.examples-section {
border-top: 1px solid #30363d;
}
}
@media (max-width: 768px) {
.main-layout {
flex-direction: column;
}
.sidebar {
width: 100%;
position: relative;
height: auto;
border-right: none;
border-bottom: 1px solid var(--gray-200);
}
.docs-section {
padding: 2rem 1.5rem;
}
.examples-section {
padding: 2rem 1.5rem;
}
}
</style>
</head>
<body>
<header>
<div class="header-content">
<div class="brand">
<div class="brand-icon">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z"/>
</svg>
</div>
<div class="brand-text">
<h1>Claude Code UI</h1>
<div class="subtitle">API Documentation</div>
</div>
</div>
<a href="/" class="back-link">
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"/>
</svg>
Back to App
</a>
</div>
</header>
<div class="main-layout">
<nav class="sidebar">
<div class="sidebar-title">Getting Started</div>
<a href="#authentication">Authentication</a>
<a href="#credentials">GitHub Credentials</a>
<div class="sidebar-title">API Reference</div>
<a href="#agent">Agent</a>
<div class="sidebar-title">Examples</div>
<a href="#usage-examples">Usage Patterns</a>
</nav>
<div class="content-wrapper">
<!-- Intro Section -->
<div class="section-row">
<div class="docs-section">
<div class="intro">
<p><strong>Programmatically trigger AI agents to work on projects.</strong> Clone GitHub repositories or use existing project paths. Perfect for CI/CD pipelines, automated code reviews, and bulk processing.</p>
</div>
<section id="authentication">
<h2>Authentication</h2>
<p>All API requests require authentication using an API key in the <code>X-API-Key</code> header.</p>
<p>Generate API keys in Settings → API & Tokens.</p>
</section>
<section id="credentials">
<h3>GitHub Credentials</h3>
<p>For private repositories, store a GitHub token in settings or pass it with each request.</p>
<div class="note">
<strong>Note:</strong> GitHub tokens in the request override stored tokens.
</div>
</section>
</div>
<div class="examples-section">
<div class="example-block">
<h4>Authentication Header</h4>
<pre><code class="language-http">X-API-Key: ck_your_api_key_here</code></pre>
</div>
</div>
</div>
<!-- Agent API Section -->
<div class="section-row">
<div class="docs-section">
<section id="agent">
<h2>Agent</h2>
<div class="endpoint">
<div class="endpoint-header">
<span class="method method-post">POST</span>
<span class="endpoint-path"><span class="api-url">http://localhost:3001</span>/api/agent</span>
</div>
<p>Trigger an AI agent (Claude or Cursor) to work on a project.</p>
<h4>Request Body Parameters</h4>
<table>
<thead>
<tr>
<th>Parameter</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>githubUrl</code></td>
<td>string</td>
<td><span class="badge badge-optional">Conditional</span></td>
<td>GitHub repository URL to clone. If path exists with same repo, reuses it. If path exists with different repo, returns error.</td>
</tr>
<tr>
<td><code>projectPath</code></td>
<td>string</td>
<td><span class="badge badge-optional">Conditional</span></td>
<td>Path to existing project OR destination for cloning. If omitted with <code>githubUrl</code>, auto-generates path. If used alone, must point to existing project directory.</td>
</tr>
<tr>
<td><code>message</code></td>
<td>string</td>
<td><span class="badge badge-required">Required</span></td>
<td>Task for the AI agent</td>
</tr>
<tr>
<td><code>provider</code></td>
<td>string</td>
<td><span class="badge badge-optional">Optional</span></td>
<td><code>claude</code> or <code>cursor</code> (default: <code>claude</code>)</td>
</tr>
<tr>
<td><code>stream</code></td>
<td>boolean</td>
<td><span class="badge badge-optional">Optional</span></td>
<td>Enable streaming (default: <code>true</code>)</td>
</tr>
<tr>
<td><code>model</code></td>
<td>string</td>
<td><span class="badge badge-optional">Optional</span></td>
<td>Model to use (for Cursor)</td>
</tr>
<tr>
<td><code>cleanup</code></td>
<td>boolean</td>
<td><span class="badge badge-optional">Optional</span></td>
<td>Auto-cleanup after completion (default: <code>true</code>). Only applies when cloning via <code>githubUrl</code>. Existing projects specified via <code>projectPath</code> are never cleaned up.</td>
</tr>
<tr>
<td><code>githubToken</code></td>
<td>string</td>
<td><span class="badge badge-optional">Optional</span></td>
<td>GitHub token for private repos</td>
</tr>
</tbody>
</table>
<div class="note">
<strong>Path Handling Behavior:</strong><br><br>
<strong>Scenario 1:</strong> Only <code>githubUrl</code> → Clones to auto-generated temporary path<br>
<strong>Scenario 2:</strong> Only <code>projectPath</code> → Uses existing project at specified path<br>
<strong>Scenario 3:</strong> Both provided → Clones <code>githubUrl</code> to <code>projectPath</code><br><br>
<strong>Validation:</strong> If <code>projectPath</code> exists and contains a git repository, the remote URL is compared with <code>githubUrl</code>. If URLs match, the existing repo is reused. If URLs differ, an error is returned.
</div>
<h4>Response (Streaming)</h4>
<p>Server-sent events (SSE) format with real-time updates. Content-Type: <code>text/event-stream</code></p>
<h4>Response (Non-Streaming)</h4>
<p>JSON object containing session details, assistant messages only (filtered), and token usage summary. Content-Type: <code>application/json</code></p>
<h4>Error Response</h4>
<p>Returns error details with appropriate HTTP status code.</p>
</div>
</section>
</div>
<div class="examples-section">
<div class="example-block">
<h4>Basic Request</h4>
<div class="tab-buttons">
<button class="tab-button active" onclick="showTab('curl-basic')">cURL</button>
<button class="tab-button" onclick="showTab('js-basic')">JavaScript</button>
<button class="tab-button" onclick="showTab('python-basic')">Python</button>
</div>
<div class="tab-content active" id="curl-basic">
<pre><code class="language-bash">curl -X POST <span class="api-url">http://localhost:3001</span>/api/agent \
-H "Content-Type: application/json" \
-H "X-API-Key: ck_..." \
-d '{
"githubUrl": "https://github.com/user/repo",
"message": "Add error handling to main.js"
}'</code></pre>
</div>
<div class="tab-content" id="js-basic">
<pre><code class="language-javascript">const response = await fetch('<span class="api-url">http://localhost:3001</span>/api/agent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': process.env.CLAUDE_API_KEY
},
body: JSON.stringify({
githubUrl: 'https://github.com/user/repo',
message: 'Add error handling',
stream: false
})
});
const result = await response.json();</code></pre>
</div>
<div class="tab-content" id="python-basic">
<pre><code class="language-python">import requests
import os
response = requests.post(
'<span class="api-url">http://localhost:3001</span>/api/agent',
headers={
'Content-Type': 'application/json',
'X-API-Key': os.environ['CLAUDE_API_KEY']
},
json={
'githubUrl': 'https://github.com/user/repo',
'message': 'Add error handling',
'stream': False
}
)
print(response.json())</code></pre>
</div>
</div>
<div class="example-block">
<h4>Streaming Response</h4>
<pre><code class="language-javascript">data: {"type":"status","message":"Repository cloned"}
data: {"type":"thinking","content":"Analyzing..."}
data: {"type":"tool_use","tool":"read_file"}
data: {"type":"content","content":"Done!"}
data: {"type":"done"}</code></pre>
</div>
<div class="example-block">
<h4>Non-Streaming Response</h4>
<pre><code class="language-json">{
"success": true,
"sessionId": "abc123",
"messages": [
{
"type": "assistant",
"message": {
"role": "assistant",
"content": [
{
"type": "text",
"text": "I've completed the task..."
}
],
"usage": {
"input_tokens": 150,
"output_tokens": 50
}
}
}
],
"tokens": {
"inputTokens": 150,
"outputTokens": 50,
"cacheReadTokens": 0,
"cacheCreationTokens": 0,
"totalTokens": 200
},
"projectPath": "/path/to/project"
}</code></pre>
</div>
<div class="example-block">
<h4>Error Response</h4>
<pre><code class="language-json">{
"success": false,
"error": "Directory exists with different repo"
}</code></pre>
</div>
</div>
</div>
<!-- Usage Patterns Section -->
<div class="section-row">
<div class="docs-section">
<section id="usage-examples">
<h2>Usage Patterns</h2>
<h3>Clone and Process Repository</h3>
<p>Clone a repository to an auto-generated temporary path and process it.</p>
<h3>Use Existing Project</h3>
<p>Work with an existing project at a specific path.</p>
<h3>Clone to Specific Path</h3>
<p>Clone a repository to a custom location for later reuse.</p>
<h3>CI/CD Integration</h3>
<p>Integrate with GitHub Actions or other CI/CD pipelines.</p>
</section>
</div>
<div class="examples-section">
<div class="example-block">
<h4>Use Existing Project</h4>
<pre><code class="language-bash">curl -X POST <span class="api-url">http://localhost:3001</span>/api/agent \
-H "Content-Type: application/json" \
-H "X-API-Key: ck_..." \
-d '{
"projectPath": "/home/user/my-project",
"message": "Refactor database queries"
}'</code></pre>
</div>
<div class="example-block">
<h4>Clone to Custom Path</h4>
<pre><code class="language-bash">curl -X POST <span class="api-url">http://localhost:3001</span>/api/agent \
-H "Content-Type: application/json" \
-H "X-API-Key: ck_..." \
-d '{
"githubUrl": "https://github.com/user/repo",
"projectPath": "/tmp/my-location",
"message": "Review security",
"cleanup": false
}'</code></pre>
</div>
<div class="example-block">
<h4>CI/CD (GitHub Actions)</h4>
<pre><code class="language-yaml">- name: Trigger Agent
run: |
curl -X POST ${{ secrets.API_URL }}/api/agent \
-H "X-API-Key: ${{ secrets.API_KEY }}" \
-H "Content-Type: application/json" \
-d '{
"githubUrl": "${{ github.repository }}",
"message": "Review for security",
"githubToken": "${{ secrets.GITHUB_TOKEN }}"
}'</code></pre>
</div>
</div>
</div>
</div>
</div>
<script>
// Dynamic URL replacement
const apiUrl = window.location.origin;
document.querySelectorAll('.api-url').forEach(el => {
el.textContent = apiUrl;
});
// Tab switching
function showTab(tabName) {
const parentBlock = event.target.closest('.example-block');
if (!parentBlock) return;
parentBlock.querySelectorAll('.tab-content').forEach(tab => {
tab.classList.remove('active');
});
parentBlock.querySelectorAll('.tab-button').forEach(btn => {
btn.classList.remove('active');
});
const targetTab = parentBlock.querySelector('#' + tabName);
if (targetTab) {
targetTab.classList.add('active');
event.target.classList.add('active');
}
}
</script>
<!-- Prism.js -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-yaml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-http.min.js"></script>
</body>
</html>

View File

@@ -378,6 +378,11 @@ async function queryClaudeSDK(command, options = {}, ws) {
capturedSessionId = message.session_id; capturedSessionId = message.session_id;
addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir); addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
// Set session ID on writer
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
ws.setSessionId(capturedSessionId);
}
// Send session-created event only once for new sessions // Send session-created event only once for new sessions
if (!sessionId && !sessionCreatedSent) { if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true; sessionCreatedSent = true;

View File

@@ -94,6 +94,11 @@ async function spawnCursor(command, options = {}, ws) {
activeCursorProcesses.set(capturedSessionId, cursorProcess); activeCursorProcesses.set(capturedSessionId, cursorProcess);
} }
// Set session ID on writer (for API endpoint compatibility)
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
ws.setSessionId(capturedSessionId);
}
// Send session-created event only once for new sessions // Send session-created event only once for new sessions
if (!sessionId && !sessionCreatedSent) { if (!sessionId && !sessionCreatedSent) {
sessionCreatedSent = true; sessionCreatedSent = true;

View File

@@ -1,6 +1,7 @@
import Database from 'better-sqlite3'; import Database from 'better-sqlite3';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import crypto from 'crypto';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname } from 'path'; import { dirname } from 'path';
@@ -94,8 +95,169 @@ const userDb = {
} }
}; };
// API Keys database operations
const apiKeysDb = {
// Generate a new API key
generateApiKey: () => {
return 'ck_' + crypto.randomBytes(32).toString('hex');
},
// Create a new API key
createApiKey: (userId, keyName) => {
try {
const apiKey = apiKeysDb.generateApiKey();
const stmt = db.prepare('INSERT INTO api_keys (user_id, key_name, api_key) VALUES (?, ?, ?)');
const result = stmt.run(userId, keyName, apiKey);
return { id: result.lastInsertRowid, keyName, apiKey };
} catch (err) {
throw err;
}
},
// Get all API keys for a user
getApiKeys: (userId) => {
try {
const rows = db.prepare('SELECT id, key_name, api_key, created_at, last_used, is_active FROM api_keys WHERE user_id = ? ORDER BY created_at DESC').all(userId);
return rows;
} catch (err) {
throw err;
}
},
// Validate API key and get user
validateApiKey: (apiKey) => {
try {
const row = db.prepare(`
SELECT u.id, u.username, ak.id as api_key_id
FROM api_keys ak
JOIN users u ON ak.user_id = u.id
WHERE ak.api_key = ? AND ak.is_active = 1 AND u.is_active = 1
`).get(apiKey);
if (row) {
// Update last_used timestamp
db.prepare('UPDATE api_keys SET last_used = CURRENT_TIMESTAMP WHERE id = ?').run(row.api_key_id);
}
return row;
} catch (err) {
throw err;
}
},
// Delete an API key
deleteApiKey: (userId, apiKeyId) => {
try {
const stmt = db.prepare('DELETE FROM api_keys WHERE id = ? AND user_id = ?');
const result = stmt.run(apiKeyId, userId);
return result.changes > 0;
} catch (err) {
throw err;
}
},
// Toggle API key active status
toggleApiKey: (userId, apiKeyId, isActive) => {
try {
const stmt = db.prepare('UPDATE api_keys SET is_active = ? WHERE id = ? AND user_id = ?');
const result = stmt.run(isActive ? 1 : 0, apiKeyId, userId);
return result.changes > 0;
} catch (err) {
throw err;
}
}
};
// User credentials database operations (for GitHub tokens, GitLab tokens, etc.)
const credentialsDb = {
// Create a new credential
createCredential: (userId, credentialName, credentialType, credentialValue, description = null) => {
try {
const stmt = db.prepare('INSERT INTO user_credentials (user_id, credential_name, credential_type, credential_value, description) VALUES (?, ?, ?, ?, ?)');
const result = stmt.run(userId, credentialName, credentialType, credentialValue, description);
return { id: result.lastInsertRowid, credentialName, credentialType };
} catch (err) {
throw err;
}
},
// Get all credentials for a user, optionally filtered by type
getCredentials: (userId, credentialType = null) => {
try {
let query = 'SELECT id, credential_name, credential_type, description, created_at, is_active FROM user_credentials WHERE user_id = ?';
const params = [userId];
if (credentialType) {
query += ' AND credential_type = ?';
params.push(credentialType);
}
query += ' ORDER BY created_at DESC';
const rows = db.prepare(query).all(...params);
return rows;
} catch (err) {
throw err;
}
},
// Get active credential value for a user by type (returns most recent active)
getActiveCredential: (userId, credentialType) => {
try {
const row = db.prepare('SELECT credential_value FROM user_credentials WHERE user_id = ? AND credential_type = ? AND is_active = 1 ORDER BY created_at DESC LIMIT 1').get(userId, credentialType);
return row?.credential_value || null;
} catch (err) {
throw err;
}
},
// Delete a credential
deleteCredential: (userId, credentialId) => {
try {
const stmt = db.prepare('DELETE FROM user_credentials WHERE id = ? AND user_id = ?');
const result = stmt.run(credentialId, userId);
return result.changes > 0;
} catch (err) {
throw err;
}
},
// Toggle credential active status
toggleCredential: (userId, credentialId, isActive) => {
try {
const stmt = db.prepare('UPDATE user_credentials SET is_active = ? WHERE id = ? AND user_id = ?');
const result = stmt.run(isActive ? 1 : 0, credentialId, userId);
return result.changes > 0;
} catch (err) {
throw err;
}
}
};
// Backward compatibility - keep old names pointing to new system
const githubTokensDb = {
createGithubToken: (userId, tokenName, githubToken, description = null) => {
return credentialsDb.createCredential(userId, tokenName, 'github_token', githubToken, description);
},
getGithubTokens: (userId) => {
return credentialsDb.getCredentials(userId, 'github_token');
},
getActiveGithubToken: (userId) => {
return credentialsDb.getActiveCredential(userId, 'github_token');
},
deleteGithubToken: (userId, tokenId) => {
return credentialsDb.deleteCredential(userId, tokenId);
},
toggleGithubToken: (userId, tokenId, isActive) => {
return credentialsDb.toggleCredential(userId, tokenId, isActive);
}
};
export { export {
db, db,
initializeDatabase, initializeDatabase,
userDb userDb,
apiKeysDb,
credentialsDb,
githubTokensDb // Backward compatibility
}; };

View File

@@ -13,4 +13,37 @@ CREATE TABLE IF NOT EXISTS users (
-- Indexes for performance -- Indexes for performance
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username); CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active); CREATE INDEX IF NOT EXISTS idx_users_active ON users(is_active);
-- API Keys table for external API access
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
key_name TEXT NOT NULL,
api_key TEXT UNIQUE NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
last_used DATETIME,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_api_keys_key ON api_keys(api_key);
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_active ON api_keys(is_active);
-- User credentials table for storing various tokens/credentials (GitHub, GitLab, etc.)
CREATE TABLE IF NOT EXISTS user_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
credential_name TEXT NOT NULL,
credential_type TEXT NOT NULL, -- 'github_token', 'gitlab_token', 'bitbucket_token', etc.
credential_value TEXT NOT NULL,
description TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN DEFAULT 1,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_user_credentials_user_id ON user_credentials(user_id);
CREATE INDEX IF NOT EXISTS idx_user_credentials_type ON user_credentials(credential_type);
CREATE INDEX IF NOT EXISTS idx_user_credentials_active ON user_credentials(is_active);

View File

@@ -47,6 +47,8 @@ import cursorRoutes from './routes/cursor.js';
import taskmasterRoutes from './routes/taskmaster.js'; import taskmasterRoutes from './routes/taskmaster.js';
import mcpUtilsRoutes from './routes/mcp-utils.js'; import mcpUtilsRoutes from './routes/mcp-utils.js';
import commandsRoutes from './routes/commands.js'; import commandsRoutes from './routes/commands.js';
import settingsRoutes from './routes/settings.js';
import agentRoutes from './routes/agent.js';
import { initializeDatabase } from './database/db.js'; import { initializeDatabase } from './database/db.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js'; import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
@@ -196,6 +198,15 @@ app.use('/api/mcp-utils', authenticateToken, mcpUtilsRoutes);
// Commands API Routes (protected) // Commands API Routes (protected)
app.use('/api/commands', authenticateToken, commandsRoutes); app.use('/api/commands', authenticateToken, commandsRoutes);
// Settings API Routes (protected)
app.use('/api/settings', authenticateToken, settingsRoutes);
// Agent API Routes (uses API key authentication)
app.use('/api/agent', agentRoutes);
// Serve public files (like api-docs.html)
app.use(express.static(path.join(__dirname, '../public')));
// Static files served after API routes // Static files served after API routes
// Add cache control: HTML files should not be cached, but assets can be cached // Add cache control: HTML files should not be cached, but assets can be cached
app.use(express.static(path.join(__dirname, '../dist'), { app.use(express.static(path.join(__dirname, '../dist'), {

View File

@@ -523,10 +523,12 @@ async function getProjects() {
async function getSessions(projectName, limit = 5, offset = 0) { async function getSessions(projectName, limit = 5, offset = 0) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName); const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try { try {
const files = await fs.readdir(projectDir); const files = await fs.readdir(projectDir);
const jsonlFiles = files.filter(file => file.endsWith('.jsonl')); // agent-*.jsonl files contain session start data at this point. This needs to be revisited
// periodically to make sure only accurate data is there and no new functionality is added there
const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
if (jsonlFiles.length === 0) { if (jsonlFiles.length === 0) {
return { sessions: [], hasMore: false, total: 0 }; return { sessions: [], hasMore: false, total: 0 };
@@ -803,10 +805,12 @@ async function parseJsonlSessions(filePath) {
// Get messages for a specific session with pagination support // Get messages for a specific session with pagination support
async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) { async function getSessionMessages(projectName, sessionId, limit = null, offset = 0) {
const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName); const projectDir = path.join(process.env.HOME, '.claude', 'projects', projectName);
try { try {
const files = await fs.readdir(projectDir); const files = await fs.readdir(projectDir);
const jsonlFiles = files.filter(file => file.endsWith('.jsonl')); // agent-*.jsonl files contain session start data at this point. This needs to be revisited
// periodically to make sure only accurate data is there and no new functionality is added there
const jsonlFiles = files.filter(file => file.endsWith('.jsonl') && !file.startsWith('agent-'));
if (jsonlFiles.length === 0) { if (jsonlFiles.length === 0) {
return { messages: [], total: 0, hasMore: false }; return { messages: [], total: 0, hasMore: false };

559
server/routes/agent.js Normal file
View File

@@ -0,0 +1,559 @@
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;

View File

@@ -4,6 +4,8 @@ import { promisify } from 'util';
import path from 'path'; import path from 'path';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import { extractProjectDirectory } from '../projects.js'; import { extractProjectDirectory } from '../projects.js';
import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js';
const router = express.Router(); const router = express.Router();
const execAsync = promisify(exec); const execAsync = promisify(exec);
@@ -343,19 +345,24 @@ router.get('/commit-diff', async (req, res) => {
} }
}); });
// Generate commit message based on staged changes // Generate commit message based on staged changes using AI
router.post('/generate-commit-message', async (req, res) => { router.post('/generate-commit-message', async (req, res) => {
const { project, files } = req.body; const { project, files, provider = 'claude' } = req.body;
if (!project || !files || files.length === 0) { if (!project || !files || files.length === 0) {
return res.status(400).json({ error: 'Project name and files are required' }); return res.status(400).json({ error: 'Project name and files are required' });
} }
// Validate provider
if (!['claude', 'cursor'].includes(provider)) {
return res.status(400).json({ error: 'provider must be "claude" or "cursor"' });
}
try { try {
const projectPath = await getActualProjectPath(project); const projectPath = await getActualProjectPath(project);
// Get diff for selected files // Get diff for selected files
let combinedDiff = ''; let diffContext = '';
for (const file of files) { for (const file of files) {
try { try {
const { stdout } = await execAsync( const { stdout } = await execAsync(
@@ -363,17 +370,30 @@ router.post('/generate-commit-message', async (req, res) => {
{ cwd: projectPath } { cwd: projectPath }
); );
if (stdout) { if (stdout) {
combinedDiff += `\n--- ${file} ---\n${stdout}`; diffContext += `\n--- ${file} ---\n${stdout}`;
} }
} catch (error) { } catch (error) {
console.error(`Error getting diff for ${file}:`, error); console.error(`Error getting diff for ${file}:`, error);
} }
} }
// Use AI to generate commit message (simple implementation) // If no diff found, might be untracked files
// In a real implementation, you might want to use GPT or Claude API if (!diffContext.trim()) {
const message = generateSimpleCommitMessage(files, combinedDiff); // Try to get content of untracked files
for (const file of files) {
try {
const filePath = path.join(projectPath, file);
const content = await fs.readFile(filePath, 'utf-8');
diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
} catch (error) {
console.error(`Error reading file ${file}:`, error);
}
}
}
// Generate commit message using AI
const message = await generateCommitMessageWithAI(files, diffContext, provider, projectPath);
res.json({ message }); res.json({ message });
} catch (error) { } catch (error) {
console.error('Generate commit message error:', error); console.error('Generate commit message error:', error);
@@ -381,46 +401,145 @@ router.post('/generate-commit-message', async (req, res) => {
} }
}); });
// Simple commit message generator (can be replaced with AI) /**
function generateSimpleCommitMessage(files, diff) { * Generates a commit message using AI (Claude SDK or Cursor CLI)
const fileCount = files.length; * @param {Array<string>} files - List of changed files
const isMultipleFiles = fileCount > 1; * @param {string} diffContext - Git diff content
* @param {string} provider - 'claude' or 'cursor'
// Analyze the diff to determine the type of change * @param {string} projectPath - Project directory path
const additions = (diff.match(/^\+[^+]/gm) || []).length; * @returns {Promise<string>} Generated commit message
const deletions = (diff.match(/^-[^-]/gm) || []).length; */
async function generateCommitMessageWithAI(files, diffContext, provider, projectPath) {
// Determine the primary action // Create the prompt
let action = 'Update'; const prompt = `You are a git commit message generator. Based on the following file changes and diffs, generate a commit message in conventional commit format.
if (additions > 0 && deletions === 0) {
action = 'Add'; REQUIREMENTS:
} else if (deletions > 0 && additions === 0) { - Use conventional commit format: type(scope): subject
action = 'Remove'; - Include a body that explains what changed and why
} else if (additions > deletions * 2) { - Valid types: feat, fix, docs, style, refactor, perf, test, build, ci, chore
action = 'Enhance'; - Keep subject line under 50 characters
} else if (deletions > additions * 2) { - Wrap body at 72 characters
action = 'Refactor'; - Be specific and descriptive
} - Return ONLY the commit message, nothing else - no markdown, no explanations, no code blocks
// Generate message based on files FILES CHANGED:
if (isMultipleFiles) { ${files.map(f => `- ${f}`).join('\n')}
const components = new Set(files.map(f => {
const parts = f.split('/'); DIFFS:
return parts[parts.length - 2] || parts[0]; ${diffContext.substring(0, 4000)}
}));
Generate the commit message now:`;
if (components.size === 1) {
return `${action} ${[...components][0]} component`; try {
} else { // Create a simple writer that collects the response
return `${action} multiple components`; let responseText = '';
const writer = {
send: (data) => {
try {
const parsed = typeof data === 'string' ? JSON.parse(data) : data;
console.log('🔍 Writer received message type:', parsed.type);
// Handle different message formats from Claude SDK and Cursor CLI
// Claude SDK sends: {type: 'claude-response', data: {message: {content: [...]}}}
if (parsed.type === 'claude-response' && parsed.data) {
const message = parsed.data.message || parsed.data;
console.log('📦 Claude response message:', JSON.stringify(message, null, 2).substring(0, 500));
if (message.content && Array.isArray(message.content)) {
// Extract text from content array
for (const item of message.content) {
if (item.type === 'text' && item.text) {
console.log('✅ Extracted text chunk:', item.text.substring(0, 100));
responseText += item.text;
}
}
}
}
// Cursor CLI sends: {type: 'cursor-output', output: '...'}
else if (parsed.type === 'cursor-output' && parsed.output) {
console.log('✅ Cursor output:', parsed.output.substring(0, 100));
responseText += parsed.output;
}
// Also handle direct text messages
else if (parsed.type === 'text' && parsed.text) {
console.log('✅ Direct text:', parsed.text.substring(0, 100));
responseText += parsed.text;
}
} catch (e) {
// Ignore parse errors
console.error('Error parsing writer data:', e);
}
},
setSessionId: () => {}, // No-op for this use case
};
console.log('🚀 Calling AI agent with provider:', provider);
console.log('📝 Prompt length:', prompt.length);
// Call the appropriate agent
if (provider === 'claude') {
await queryClaudeSDK(prompt, {
cwd: projectPath,
permissionMode: 'bypassPermissions',
model: 'sonnet'
}, writer);
} else if (provider === 'cursor') {
await spawnCursor(prompt, {
cwd: projectPath,
skipPermissions: true
}, writer);
} }
} else {
const fileName = files[0].split('/').pop(); console.log('📊 Total response text collected:', responseText.length, 'characters');
const componentName = fileName.replace(/\.(jsx?|tsx?|css|scss)$/, ''); console.log('📄 Response preview:', responseText.substring(0, 200));
return `${action} ${componentName}`;
// Clean up the response
const cleanedMessage = cleanCommitMessage(responseText);
console.log('🧹 Cleaned message:', cleanedMessage.substring(0, 200));
return cleanedMessage || 'chore: update files';
} catch (error) {
console.error('Error generating commit message with AI:', error);
// Fallback to simple message
return `chore: update ${files.length} file${files.length !== 1 ? 's' : ''}`;
} }
} }
/**
* Cleans the AI-generated commit message by removing markdown, code blocks, and extra formatting
* @param {string} text - Raw AI response
* @returns {string} Clean commit message
*/
function cleanCommitMessage(text) {
if (!text || !text.trim()) {
return '';
}
let cleaned = text.trim();
// Remove markdown code blocks
cleaned = cleaned.replace(/```[a-z]*\n/g, '');
cleaned = cleaned.replace(/```/g, '');
// Remove markdown headers
cleaned = cleaned.replace(/^#+\s*/gm, '');
// Remove leading/trailing quotes
cleaned = cleaned.replace(/^["']|["']$/g, '');
// If there are multiple lines, take everything (subject + body)
// Just clean up extra blank lines
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
// Remove any explanatory text before the actual commit message
// Look for conventional commit pattern and start from there
const conventionalCommitMatch = cleaned.match(/(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\(.+?\))?:.+/s);
if (conventionalCommitMatch) {
cleaned = cleaned.substring(cleaned.indexOf(conventionalCommitMatch[0]));
}
return cleaned.trim();
}
// Get remote status (ahead/behind commits with smart remote detection) // Get remote status (ahead/behind commits with smart remote detection)
router.get('/remote-status', async (req, res) => { router.get('/remote-status', async (req, res) => {
const { project } = req.query; const { project } = req.query;

178
server/routes/settings.js Normal file
View File

@@ -0,0 +1,178 @@
import express from 'express';
import { apiKeysDb, credentialsDb } from '../database/db.js';
const router = express.Router();
// ===============================
// API Keys Management
// ===============================
// Get all API keys for the authenticated user
router.get('/api-keys', async (req, res) => {
try {
const apiKeys = apiKeysDb.getApiKeys(req.user.id);
// Don't send the full API key in the list for security
const sanitizedKeys = apiKeys.map(key => ({
...key,
api_key: key.api_key.substring(0, 10) + '...'
}));
res.json({ apiKeys: sanitizedKeys });
} catch (error) {
console.error('Error fetching API keys:', error);
res.status(500).json({ error: 'Failed to fetch API keys' });
}
});
// Create a new API key
router.post('/api-keys', async (req, res) => {
try {
const { keyName } = req.body;
if (!keyName || !keyName.trim()) {
return res.status(400).json({ error: 'Key name is required' });
}
const result = apiKeysDb.createApiKey(req.user.id, keyName.trim());
res.json({
success: true,
apiKey: result
});
} catch (error) {
console.error('Error creating API key:', error);
res.status(500).json({ error: 'Failed to create API key' });
}
});
// Delete an API key
router.delete('/api-keys/:keyId', async (req, res) => {
try {
const { keyId } = req.params;
const success = apiKeysDb.deleteApiKey(req.user.id, parseInt(keyId));
if (success) {
res.json({ success: true });
} else {
res.status(404).json({ error: 'API key not found' });
}
} catch (error) {
console.error('Error deleting API key:', error);
res.status(500).json({ error: 'Failed to delete API key' });
}
});
// Toggle API key active status
router.patch('/api-keys/:keyId/toggle', async (req, res) => {
try {
const { keyId } = req.params;
const { isActive } = req.body;
if (typeof isActive !== 'boolean') {
return res.status(400).json({ error: 'isActive must be a boolean' });
}
const success = apiKeysDb.toggleApiKey(req.user.id, parseInt(keyId), isActive);
if (success) {
res.json({ success: true });
} else {
res.status(404).json({ error: 'API key not found' });
}
} catch (error) {
console.error('Error toggling API key:', error);
res.status(500).json({ error: 'Failed to toggle API key' });
}
});
// ===============================
// Generic Credentials Management
// ===============================
// Get all credentials for the authenticated user (optionally filtered by type)
router.get('/credentials', async (req, res) => {
try {
const { type } = req.query;
const credentials = credentialsDb.getCredentials(req.user.id, type || null);
// Don't send the actual credential values for security
res.json({ credentials });
} catch (error) {
console.error('Error fetching credentials:', error);
res.status(500).json({ error: 'Failed to fetch credentials' });
}
});
// Create a new credential
router.post('/credentials', async (req, res) => {
try {
const { credentialName, credentialType, credentialValue, description } = req.body;
if (!credentialName || !credentialName.trim()) {
return res.status(400).json({ error: 'Credential name is required' });
}
if (!credentialType || !credentialType.trim()) {
return res.status(400).json({ error: 'Credential type is required' });
}
if (!credentialValue || !credentialValue.trim()) {
return res.status(400).json({ error: 'Credential value is required' });
}
const result = credentialsDb.createCredential(
req.user.id,
credentialName.trim(),
credentialType.trim(),
credentialValue.trim(),
description?.trim() || null
);
res.json({
success: true,
credential: result
});
} catch (error) {
console.error('Error creating credential:', error);
res.status(500).json({ error: 'Failed to create credential' });
}
});
// Delete a credential
router.delete('/credentials/:credentialId', async (req, res) => {
try {
const { credentialId } = req.params;
const success = credentialsDb.deleteCredential(req.user.id, parseInt(credentialId));
if (success) {
res.json({ success: true });
} else {
res.status(404).json({ error: 'Credential not found' });
}
} catch (error) {
console.error('Error deleting credential:', error);
res.status(500).json({ error: 'Failed to delete credential' });
}
});
// Toggle credential active status
router.patch('/credentials/:credentialId/toggle', async (req, res) => {
try {
const { credentialId } = req.params;
const { isActive } = req.body;
if (typeof isActive !== 'boolean') {
return res.status(400).json({ error: 'isActive must be a boolean' });
}
const success = credentialsDb.toggleCredential(req.user.id, parseInt(credentialId), isActive);
if (success) {
res.json({ success: true });
} else {
res.status(404).json({ error: 'Credential not found' });
}
} catch (error) {
console.error('Error toggling credential:', error);
res.status(500).json({ error: 'Failed to toggle credential' });
}
});
export default router;

View File

@@ -0,0 +1,398 @@
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Key, Plus, Trash2, Eye, EyeOff, Copy, Check, Github } from 'lucide-react';
function ApiKeysSettings() {
const [apiKeys, setApiKeys] = useState([]);
const [githubTokens, setGithubTokens] = useState([]);
const [loading, setLoading] = useState(true);
const [showNewKeyForm, setShowNewKeyForm] = useState(false);
const [showNewTokenForm, setShowNewTokenForm] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newTokenName, setNewTokenName] = useState('');
const [newGithubToken, setNewGithubToken] = useState('');
const [showToken, setShowToken] = useState({});
const [copiedKey, setCopiedKey] = useState(null);
const [newlyCreatedKey, setNewlyCreatedKey] = useState(null);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
const token = localStorage.getItem('auth-token');
// Fetch API keys
const apiKeysRes = await fetch('/api/settings/api-keys', {
headers: { 'Authorization': `Bearer ${token}` }
});
const apiKeysData = await apiKeysRes.json();
setApiKeys(apiKeysData.apiKeys || []);
// Fetch GitHub tokens
const githubRes = await fetch('/api/settings/github-tokens', {
headers: { 'Authorization': `Bearer ${token}` }
});
const githubData = await githubRes.json();
setGithubTokens(githubData.tokens || []);
} catch (error) {
console.error('Error fetching settings:', error);
} finally {
setLoading(false);
}
};
const createApiKey = async () => {
if (!newKeyName.trim()) return;
try {
const token = localStorage.getItem('auth-token');
const res = await fetch('/api/settings/api-keys', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ keyName: newKeyName })
});
const data = await res.json();
if (data.success) {
setNewlyCreatedKey(data.apiKey);
setNewKeyName('');
setShowNewKeyForm(false);
fetchData();
}
} catch (error) {
console.error('Error creating API key:', error);
}
};
const deleteApiKey = async (keyId) => {
if (!confirm('Are you sure you want to delete this API key?')) return;
try {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/api-keys/${keyId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
fetchData();
} catch (error) {
console.error('Error deleting API key:', error);
}
};
const toggleApiKey = async (keyId, isActive) => {
try {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/api-keys/${keyId}/toggle`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ isActive: !isActive })
});
fetchData();
} catch (error) {
console.error('Error toggling API key:', error);
}
};
const createGithubToken = async () => {
if (!newTokenName.trim() || !newGithubToken.trim()) return;
try {
const token = localStorage.getItem('auth-token');
const res = await fetch('/api/settings/github-tokens', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
tokenName: newTokenName,
githubToken: newGithubToken
})
});
const data = await res.json();
if (data.success) {
setNewTokenName('');
setNewGithubToken('');
setShowNewTokenForm(false);
fetchData();
}
} catch (error) {
console.error('Error creating GitHub token:', error);
}
};
const deleteGithubToken = async (tokenId) => {
if (!confirm('Are you sure you want to delete this GitHub token?')) return;
try {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/github-tokens/${tokenId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
fetchData();
} catch (error) {
console.error('Error deleting GitHub token:', error);
}
};
const toggleGithubToken = async (tokenId, isActive) => {
try {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/github-tokens/${tokenId}/toggle`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ isActive: !isActive })
});
fetchData();
} catch (error) {
console.error('Error toggling GitHub token:', error);
}
};
const copyToClipboard = (text, id) => {
navigator.clipboard.writeText(text);
setCopiedKey(id);
setTimeout(() => setCopiedKey(null), 2000);
};
if (loading) {
return <div className="text-muted-foreground">Loading...</div>;
}
return (
<div className="space-y-8">
{/* New API Key Alert */}
{newlyCreatedKey && (
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<h4 className="font-semibold text-yellow-500 mb-2"> Save Your API Key</h4>
<p className="text-sm text-muted-foreground mb-3">
This is the only time you'll see this key. Store it securely.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-background/50 rounded font-mono text-sm break-all">
{newlyCreatedKey.apiKey}
</code>
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(newlyCreatedKey.apiKey, 'new')}
>
{copiedKey === 'new' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<Button
size="sm"
variant="ghost"
className="mt-3"
onClick={() => setNewlyCreatedKey(null)}
>
I've saved it
</Button>
</div>
)}
{/* API Keys Section */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Key className="h-5 w-5" />
<h3 className="text-lg font-semibold">API Keys</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewKeyForm(!showNewKeyForm)}
>
<Plus className="h-4 w-4 mr-1" />
New API Key
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
Generate API keys to access the external API from other applications.
</p>
{showNewKeyForm && (
<div className="mb-4 p-4 border rounded-lg bg-card">
<Input
placeholder="API Key Name (e.g., Production Server)"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="mb-2"
/>
<div className="flex gap-2">
<Button onClick={createApiKey}>Create</Button>
<Button variant="outline" onClick={() => setShowNewKeyForm(false)}>
Cancel
</Button>
</div>
</div>
)}
<div className="space-y-2">
{apiKeys.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No API keys created yet.</p>
) : (
apiKeys.map((key) => (
<div
key={key.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<div className="font-medium">{key.key_name}</div>
<code className="text-xs text-muted-foreground">{key.api_key}</code>
<div className="text-xs text-muted-foreground mt-1">
Created: {new Date(key.created_at).toLocaleDateString()}
{key.last_used && ` • Last used: ${new Date(key.last_used).toLocaleDateString()}`}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={key.is_active ? 'outline' : 'secondary'}
onClick={() => toggleApiKey(key.id, key.is_active)}
>
{key.is_active ? 'Active' : 'Inactive'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteApiKey(key.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
{/* GitHub Tokens Section */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Github className="h-5 w-5" />
<h3 className="text-lg font-semibold">GitHub Tokens</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewTokenForm(!showNewTokenForm)}
>
<Plus className="h-4 w-4 mr-1" />
Add Token
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
Add GitHub Personal Access Tokens to clone private repositories via the external API.
</p>
{showNewTokenForm && (
<div className="mb-4 p-4 border rounded-lg bg-card">
<Input
placeholder="Token Name (e.g., Personal Repos)"
value={newTokenName}
onChange={(e) => setNewTokenName(e.target.value)}
className="mb-2"
/>
<div className="relative">
<Input
type={showToken['new'] ? 'text' : 'password'}
placeholder="GitHub Personal Access Token (ghp_...)"
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
className="mb-2 pr-10"
/>
<button
type="button"
onClick={() => setShowToken({ ...showToken, new: !showToken['new'] })}
className="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground"
>
{showToken['new'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<div className="flex gap-2">
<Button onClick={createGithubToken}>Add Token</Button>
<Button variant="outline" onClick={() => {
setShowNewTokenForm(false);
setNewTokenName('');
setNewGithubToken('');
}}>
Cancel
</Button>
</div>
</div>
)}
<div className="space-y-2">
{githubTokens.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No GitHub tokens added yet.</p>
) : (
githubTokens.map((token) => (
<div
key={token.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<div className="font-medium">{token.token_name}</div>
<div className="text-xs text-muted-foreground mt-1">
Added: {new Date(token.created_at).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={token.is_active ? 'outline' : 'secondary'}
onClick={() => toggleGithubToken(token.id, token.is_active)}
>
{token.is_active ? 'Active' : 'Inactive'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteGithubToken(token.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
{/* Documentation Link */}
<div className="p-4 bg-muted/50 rounded-lg">
<h4 className="font-semibold mb-2">External API Documentation</h4>
<p className="text-sm text-muted-foreground mb-3">
Learn how to use the external API to trigger Claude/Cursor sessions from your applications.
</p>
<a
href="/EXTERNAL_API.md"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline"
>
View API Documentation
</a>
</div>
</div>
);
}
export default ApiKeysSettings;

View File

@@ -0,0 +1,417 @@
import { useState, useEffect } from 'react';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Key, Plus, Trash2, Eye, EyeOff, Copy, Check, Github, ExternalLink } from 'lucide-react';
function CredentialsSettings() {
const [apiKeys, setApiKeys] = useState([]);
const [githubCredentials, setGithubCredentials] = useState([]);
const [loading, setLoading] = useState(true);
const [showNewKeyForm, setShowNewKeyForm] = useState(false);
const [showNewGithubForm, setShowNewGithubForm] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [newGithubName, setNewGithubName] = useState('');
const [newGithubToken, setNewGithubToken] = useState('');
const [newGithubDescription, setNewGithubDescription] = useState('');
const [showToken, setShowToken] = useState({});
const [copiedKey, setCopiedKey] = useState(null);
const [newlyCreatedKey, setNewlyCreatedKey] = useState(null);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
setLoading(true);
const token = localStorage.getItem('auth-token');
// Fetch API keys
const apiKeysRes = await fetch('/api/settings/api-keys', {
headers: { 'Authorization': `Bearer ${token}` }
});
const apiKeysData = await apiKeysRes.json();
setApiKeys(apiKeysData.apiKeys || []);
// Fetch GitHub credentials only
const credentialsRes = await fetch('/api/settings/credentials?type=github_token', {
headers: { 'Authorization': `Bearer ${token}` }
});
const credentialsData = await credentialsRes.json();
setGithubCredentials(credentialsData.credentials || []);
} catch (error) {
console.error('Error fetching settings:', error);
} finally {
setLoading(false);
}
};
const createApiKey = async () => {
if (!newKeyName.trim()) return;
try {
const token = localStorage.getItem('auth-token');
const res = await fetch('/api/settings/api-keys', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ keyName: newKeyName })
});
const data = await res.json();
if (data.success) {
setNewlyCreatedKey(data.apiKey);
setNewKeyName('');
setShowNewKeyForm(false);
fetchData();
}
} catch (error) {
console.error('Error creating API key:', error);
}
};
const deleteApiKey = async (keyId) => {
if (!confirm('Are you sure you want to delete this API key?')) return;
try {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/api-keys/${keyId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
fetchData();
} catch (error) {
console.error('Error deleting API key:', error);
}
};
const toggleApiKey = async (keyId, isActive) => {
try {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/api-keys/${keyId}/toggle`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ isActive: !isActive })
});
fetchData();
} catch (error) {
console.error('Error toggling API key:', error);
}
};
const createGithubCredential = async () => {
if (!newGithubName.trim() || !newGithubToken.trim()) return;
try {
const token = localStorage.getItem('auth-token');
const res = await fetch('/api/settings/credentials', {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
credentialName: newGithubName,
credentialType: 'github_token',
credentialValue: newGithubToken,
description: newGithubDescription
})
});
const data = await res.json();
if (data.success) {
setNewGithubName('');
setNewGithubToken('');
setNewGithubDescription('');
setShowNewGithubForm(false);
fetchData();
}
} catch (error) {
console.error('Error creating GitHub credential:', error);
}
};
const deleteGithubCredential = async (credentialId) => {
if (!confirm('Are you sure you want to delete this GitHub token?')) return;
try {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/credentials/${credentialId}`, {
method: 'DELETE',
headers: { 'Authorization': `Bearer ${token}` }
});
fetchData();
} catch (error) {
console.error('Error deleting GitHub credential:', error);
}
};
const toggleGithubCredential = async (credentialId, isActive) => {
try {
const token = localStorage.getItem('auth-token');
await fetch(`/api/settings/credentials/${credentialId}/toggle`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ isActive: !isActive })
});
fetchData();
} catch (error) {
console.error('Error toggling GitHub credential:', error);
}
};
const copyToClipboard = (text, id) => {
navigator.clipboard.writeText(text);
setCopiedKey(id);
setTimeout(() => setCopiedKey(null), 2000);
};
if (loading) {
return <div className="text-muted-foreground">Loading...</div>;
}
return (
<div className="space-y-8">
{/* New API Key Alert */}
{newlyCreatedKey && (
<div className="p-4 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
<h4 className="font-semibold text-yellow-500 mb-2"> Save Your API Key</h4>
<p className="text-sm text-muted-foreground mb-3">
This is the only time you'll see this key. Store it securely.
</p>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-background/50 rounded font-mono text-sm break-all">
{newlyCreatedKey.apiKey}
</code>
<Button
size="sm"
variant="outline"
onClick={() => copyToClipboard(newlyCreatedKey.apiKey, 'new')}
>
{copiedKey === 'new' ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<Button
size="sm"
variant="ghost"
className="mt-3"
onClick={() => setNewlyCreatedKey(null)}
>
I've saved it
</Button>
</div>
)}
{/* API Keys Section */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Key className="h-5 w-5" />
<h3 className="text-lg font-semibold">API Keys</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewKeyForm(!showNewKeyForm)}
>
<Plus className="h-4 w-4 mr-1" />
New API Key
</Button>
</div>
<div className="mb-4">
<p className="text-sm text-muted-foreground mb-2">
Generate API keys to access the external API from other applications.
</p>
<a
href="/api-docs.html"
target="_blank"
rel="noopener noreferrer"
className="text-sm text-primary hover:underline inline-flex items-center gap-1"
>
API Documentation
<ExternalLink className="h-3 w-3" />
</a>
</div>
{showNewKeyForm && (
<div className="mb-4 p-4 border rounded-lg bg-card">
<Input
placeholder="API Key Name (e.g., Production Server)"
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
className="mb-2"
/>
<div className="flex gap-2">
<Button onClick={createApiKey}>Create</Button>
<Button variant="outline" onClick={() => setShowNewKeyForm(false)}>
Cancel
</Button>
</div>
</div>
)}
<div className="space-y-2">
{apiKeys.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No API keys created yet.</p>
) : (
apiKeys.map((key) => (
<div
key={key.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<div className="font-medium">{key.key_name}</div>
<code className="text-xs text-muted-foreground">{key.api_key}</code>
<div className="text-xs text-muted-foreground mt-1">
Created: {new Date(key.created_at).toLocaleDateString()}
{key.last_used && ` • Last used: ${new Date(key.last_used).toLocaleDateString()}`}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={key.is_active ? 'outline' : 'secondary'}
onClick={() => toggleApiKey(key.id, key.is_active)}
>
{key.is_active ? 'Active' : 'Inactive'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteApiKey(key.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
{/* GitHub Credentials Section */}
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Github className="h-5 w-5" />
<h3 className="text-lg font-semibold">GitHub Credentials</h3>
</div>
<Button
size="sm"
onClick={() => setShowNewGithubForm(!showNewGithubForm)}
>
<Plus className="h-4 w-4 mr-1" />
Add Token
</Button>
</div>
<p className="text-sm text-muted-foreground mb-4">
Add GitHub Personal Access Tokens to clone private repositories. You can also pass tokens directly in API requests without storing them.
</p>
{showNewGithubForm && (
<div className="mb-4 p-4 border rounded-lg bg-card space-y-3">
<Input
placeholder="Token Name (e.g., Personal Repos)"
value={newGithubName}
onChange={(e) => setNewGithubName(e.target.value)}
/>
<div className="relative">
<Input
type={showToken['new'] ? 'text' : 'password'}
placeholder="GitHub Personal Access Token (ghp_...)"
value={newGithubToken}
onChange={(e) => setNewGithubToken(e.target.value)}
className="pr-10"
/>
<button
type="button"
onClick={() => setShowToken({ ...showToken, new: !showToken['new'] })}
className="absolute right-3 top-2.5 text-muted-foreground hover:text-foreground"
>
{showToken['new'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
<Input
placeholder="Description (optional)"
value={newGithubDescription}
onChange={(e) => setNewGithubDescription(e.target.value)}
/>
<div className="flex gap-2">
<Button onClick={createGithubCredential}>Add Token</Button>
<Button variant="outline" onClick={() => {
setShowNewGithubForm(false);
setNewGithubName('');
setNewGithubToken('');
setNewGithubDescription('');
}}>
Cancel
</Button>
</div>
<a
href="https://github.com/settings/tokens"
target="_blank"
rel="noopener noreferrer"
className="text-xs text-primary hover:underline block"
>
How to create a GitHub Personal Access Token
</a>
</div>
)}
<div className="space-y-2">
{githubCredentials.length === 0 ? (
<p className="text-sm text-muted-foreground italic">No GitHub tokens added yet.</p>
) : (
githubCredentials.map((credential) => (
<div
key={credential.id}
className="flex items-center justify-between p-3 border rounded-lg"
>
<div className="flex-1">
<div className="font-medium">{credential.credential_name}</div>
{credential.description && (
<div className="text-xs text-muted-foreground">{credential.description}</div>
)}
<div className="text-xs text-muted-foreground mt-1">
Added: {new Date(credential.created_at).toLocaleDateString()}
</div>
</div>
<div className="flex items-center gap-2">
<Button
size="sm"
variant={credential.is_active ? 'outline' : 'secondary'}
onClick={() => toggleGithubCredential(credential.id, credential.is_active)}
>
{credential.is_active ? 'Active' : 'Inactive'}
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => deleteGithubCredential(credential.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
</div>
</div>
);
}
export default CredentialsSettings;

View File

@@ -35,6 +35,22 @@ function GitPanel({ selectedProject, isMobile }) {
const textareaRef = useRef(null); const textareaRef = useRef(null);
const dropdownRef = useRef(null); const dropdownRef = useRef(null);
// Get current provider from localStorage (same as ChatInterface does)
const [provider, setProvider] = useState(() => {
return localStorage.getItem('selected-provider') || 'claude';
});
// Listen for provider changes in localStorage
useEffect(() => {
const handleStorageChange = () => {
const newProvider = localStorage.getItem('selected-provider') || 'claude';
setProvider(newProvider);
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);
useEffect(() => { useEffect(() => {
if (selectedProject) { if (selectedProject) {
fetchGitStatus(); fetchGitStatus();
@@ -435,10 +451,11 @@ function GitPanel({ selectedProject, isMobile }) {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
project: selectedProject.name, project: selectedProject.name,
files: Array.from(selectedFiles) files: Array.from(selectedFiles),
provider: provider // Pass the current provider (claude or cursor)
}) })
}); });
const data = await response.json(); const data = await response.json();
if (data.message) { if (data.message) {
setCommitMessage(data.message); setCommitMessage(data.message);

View File

@@ -2,12 +2,13 @@ import { useState, useEffect } from 'react';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Input } from './ui/input'; import { Input } from './ui/input';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { X, Plus, Settings as SettingsIcon, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen, LogIn } from 'lucide-react'; import { X, Plus, Settings as SettingsIcon, Shield, AlertTriangle, Moon, Sun, Server, Edit3, Trash2, Globe, Terminal, Zap, FolderOpen, LogIn, Key } from 'lucide-react';
import { useTheme } from '../contexts/ThemeContext'; import { useTheme } from '../contexts/ThemeContext';
import { useTasksSettings } from '../contexts/TasksSettingsContext'; import { useTasksSettings } from '../contexts/TasksSettingsContext';
import StandaloneShell from './StandaloneShell'; import StandaloneShell from './StandaloneShell';
import ClaudeLogo from './ClaudeLogo'; import ClaudeLogo from './ClaudeLogo';
import CursorLogo from './CursorLogo'; import CursorLogo from './CursorLogo';
import CredentialsSettings from './CredentialsSettings';
function Settings({ isOpen, onClose, projects = [] }) { function Settings({ isOpen, onClose, projects = [] }) {
const { isDarkMode, toggleDarkMode } = useTheme(); const { isDarkMode, toggleDarkMode } = useTheme();
@@ -677,6 +678,17 @@ function Settings({ isOpen, onClose, projects = [] }) {
> >
Tasks Tasks
</button> </button>
<button
onClick={() => setActiveTab('api')}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'api'
? 'border-blue-600 text-blue-600 dark:text-blue-400'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Key className="w-4 h-4 inline mr-2" />
API & Tokens
</button>
</div> </div>
</div> </div>
@@ -1941,6 +1953,13 @@ function Settings({ isOpen, onClose, projects = [] }) {
)} )}
</div> </div>
)} )}
{/* API & Tokens Tab */}
{activeTab === 'api' && (
<div className="space-y-6 md:space-y-8">
<CredentialsSettings />
</div>
)}
</div> </div>
</div> </div>