mirror of
https://github.com/siteboon/claudecodeui.git
synced 2025-12-11 23:59:47 +00:00
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:
785
public/api-docs.html
Normal file
785
public/api-docs.html
Normal 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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
};
|
};
|
||||||
@@ -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);
|
||||||
@@ -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'), {
|
||||||
|
|||||||
@@ -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
559
server/routes/agent.js
Normal 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;
|
||||||
@@ -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
178
server/routes/settings.js
Normal 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;
|
||||||
398
src/components/ApiKeysSettings.jsx
Normal file
398
src/components/ApiKeysSettings.jsx
Normal 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;
|
||||||
417
src/components/CredentialsSettings.jsx
Normal file
417
src/components/CredentialsSettings.jsx
Normal 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;
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user