diff --git a/README.md b/README.md
index fc985a5..3eb4b0f 100644
--- a/README.md
+++ b/README.md
@@ -1,21 +1,23 @@
-
-A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview), [Codex](https://developers.openai.com/codex), and [Gemini-CLI](https://geminicli.com/). You can use it locally or remotely to view your active projects and sessions and make changes to them from everywhere (mobile or desktop). This gives you a proper interface that works everywhere.
-
+
+---
## Screenshots
@@ -41,7 +43,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
@@ -58,8 +60,9 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla
- **File Explorer** - Interactive file tree with syntax highlighting and live editing
- **Git Explorer** - View, stage and commit your changes. You can also switch branches
- **Session Management** - Resume conversations, manage multiple sessions, and track history
+- **Plugin System** - Extend CloudCLI with custom plugins — add new tabs, backend services, and integrations. [Build your own →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)
- **TaskMaster AI Integration** *(Optional)* - Advanced project management with AI-powered task planning, PRD parsing, and workflow automation
-- **Model Compatibility** - Works with Claude Sonnet 4.5, Opus 4.5, GPT-5.2, and Gemini.
+- **Model Compatibility** - Works with Claude, GPT, and Gemini model families (see [`shared/modelConstants.js`](shared/modelConstants.js) for the full list of supported models)
## Quick Start
@@ -127,8 +130,8 @@ CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self
To use Claude Code's full functionality, you'll need to manually enable tools:
1. **Open Tools Settings** - Click the gear icon in the sidebar
-3. **Enable Selectively** - Turn on only the tools you need
-4. **Apply Settings** - Your preferences are saved locally
+2. **Enable Selectively** - Turn on only the tools you need
+3. **Apply Settings** - Your preferences are saved locally
@@ -139,6 +142,24 @@ To use Claude Code's full functionality, you'll need to manually enable tools:
**Recommended approach**: Start with basic tools enabled and add more as needed. You can always adjust these settings later.
+---
+
+## Plugins
+
+CloudCLI has a plugin system that lets you add custom tabs with their own frontend UI and optional Node.js backend. Install plugins from git repos directly in **Settings > Plugins**, or build your own.
+
+### Available Plugins
+
+| Plugin | Description |
+|---|---|
+| **[Project Stats](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** | Shows file counts, lines of code, file-type breakdown, largest files, and recently modified files for your current project |
+
+### Build Your Own
+
+**[Plugin Starter Template →](https://github.com/cloudcli-ai/cloudcli-plugin-starter)** — fork this repo to create your own plugin. It includes a working example with frontend rendering, live context updates, and RPC communication to a backend server.
+
+**[Plugin Documentation →](https://cloudcli.ai/docs/plugin-overview)** — full guide to the plugin API, manifest format, security model, and more.
+
---
## FAQ
diff --git a/README.ru.md b/README.ru.md
new file mode 100644
index 0000000..d864711
--- /dev/null
+++ b/README.ru.md
@@ -0,0 +1,218 @@
+
+
+
Cloud CLI (aka Claude Code UI)
+
+
+
+Десктопный и мобильный UI для [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [Cursor CLI](https://docs.cursor.com/en/cli/overview), [Codex](https://developers.openai.com/codex) и [Gemini-CLI](https://geminicli.com/). Его можно использовать локально или удаленно, чтобы просматривать активные проекты и сессии и вносить изменения откуда угодно, с мобильного или десктопа. Это дает полноценный интерфейс, который работает везде.
+
+
+ CloudCLI Cloud · Discord · Сообщить об ошибке · Участие в разработке
+
+
+
+
+
+
+
+
+
+## Скриншоты
+
+
+
+
+
+
+Версия для десктопа
+
+
+Основной интерфейс с обзором проекта и чатом
+
+
+Мобильный режим
+
+
+Адаптивный мобильный интерфейс с сенсорной навигацией
+
+
+
+
+Выбор CLI
+
+
+Выбор между Claude Code, Cursor CLI, Codex и Gemini CLI
+
+
+
+
+
+
+
+
+## Возможности
+
+- **Адаптивный дизайн** - одинаково хорошо работает на десктопе, планшете и телефоне, поэтому пользоваться агентами можно и с мобильных устройств
+- **Интерактивный чат-интерфейс** - встроенный чат для удобного взаимодействия с агентами
+- **Встроенный shell-терминал** - прямой доступ к CLI агентов через встроенную оболочку
+- **Файловый менеджер** - интерактивное дерево файлов с подсветкой синтаксиса и live-редактированием
+- **Git Explorer** - просмотр, stage и commit изменений, а также переключение веток
+- **Управление сессиями** - возобновление диалогов, работа с несколькими сессиями и история
+- **Интеграция с TaskMaster AI** *(опционально)* - расширенное управление проектами с AI-планированием задач, разбором PRD и автоматизацией workflows
+- **Совместимость с моделями** - работает с Claude Sonnet 4.5, Opus 4.5, GPT-5.2 и Gemini.
+
+
+## Быстрый старт
+
+### CloudCLI Cloud (рекомендуется)
+
+Самый быстрый способ начать работу: локальная настройка не требуется. Вы получаете полностью управляемую контейнеризированную среду разработки с доступом из браузера, мобильного приложения, API или любимой IDE.
+
+**[Начать с CloudCLI Cloud](https://cloudcli.ai)**
+
+
+### Self-Hosted (open source)
+
+Попробовать CloudCLI UI можно сразу через **npx** (нужен **Node.js** v22+):
+
+```bash
+npx @siteboon/claude-code-ui
+```
+
+Или установить **глобально** для постоянного использования:
+
+```bash
+npm install -g @siteboon/claude-code-ui
+cloudcli
+```
+
+Откройте `http://localhost:3001` — все существующие сессии будут обнаружены автоматически.
+
+Больше вариантов настройки, PM2, удаленный сервер и остальное описаны в **[документации →](https://cloudcli.ai/docs)**
+
+
+---
+
+## Какой вариант подойдет вам?
+
+CloudCLI UI - это open source UI-слой, на котором построен CloudCLI Cloud. Вы можете развернуть его у себя на машине или использовать CloudCLI Cloud, который добавляет полностью управляемую облачную среду, командные функции и более глубокие интеграции.
+
+| | CloudCLI UI (self-hosted) | CloudCLI Cloud |
+|---|---|---|
+| **Лучше всего подходит для** | Разработчиков, которым нужен полноценный UI для локальных агентских сессий на своей машине | Команд и разработчиков, которым нужны агенты в облаке с доступом откуда угодно |
+| **Способ доступа** | Браузер через `[yourip]:port` | Браузер, любая IDE, REST API, n8n |
+| **Настройка** | `npx @siteboon/claude-code-ui` | Настройка не требуется |
+| **Машина должна оставаться включенной** | Да | Нет |
+| **Доступ с мобильных устройств** | Любой браузер в вашей сети | Любое устройство, нативное приложение в разработке |
+| **Доступные сессии** | Все сессии автоматически обнаруживаются в `~/.claude` | Все сессии внутри вашей облачной среды |
+| **Поддерживаемые агенты** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
+| **Файловый менеджер и Git** | Да, встроены в UI | Да, встроены в UI |
+| **Конфигурация MCP** | Управляется через UI, синхронизируется с локальным `~/.claude` | Управляется через UI |
+| **Доступ из IDE** | Ваша локальная IDE | Любая IDE, подключенная к облачной среде |
+| **REST API** | Да | Да |
+| **Узел n8n** | Нет | Да |
+| **Совместная работа в команде** | Нет | Да |
+| **Стоимость платформы** | Бесплатно, open source | От $7/месяц |
+
+> В обоих вариантах используются ваши собственные AI-подписки (Claude, Cursor и т.д.) — CloudCLI предоставляет среду, а не сам AI.
+
+---
+
+## Безопасность и настройка инструментов
+
+**🔒 Важно**: все инструменты Claude Code **по умолчанию отключены**. Это предотвращает автоматический запуск потенциально опасных операций.
+
+### Включение инструментов
+
+Чтобы использовать всю функциональность Claude Code, инструменты нужно включить вручную:
+
+1. **Откройте настройки инструментов** - нажмите на иконку шестеренки в боковой панели
+2. **Включайте выборочно** - активируйте только те инструменты, которые действительно нужны
+3. **Примените настройки** - предпочтения сохраняются локально
+
+
+
+
+*Окно настройки инструментов - включайте только то, что вам нужно*
+
+
+
+**Рекомендуемый подход**: начните с базовых инструментов и добавляйте остальные по мере необходимости. Эти настройки всегда можно поменять позже.
+
+---
+## FAQ
+
+
+Чем это отличается от Claude Code Remote Control?
+
+Claude Code Remote Control позволяет отправлять сообщения в сессию, уже запущенную в локальном терминале. При этом ваша машина должна оставаться включенной, терминал должен быть открыт, а сессии завершаются примерно через 10 минут без сетевого соединения.
+
+CloudCLI UI и CloudCLI Cloud расширяют Claude Code, а не работают рядом с ним — ваши MCP-серверы, разрешения, настройки и сессии остаются теми же самыми, что и в нативном Claude Code. Ничего не дублируется и не управляется отдельно.
+
+Вот что это означает на практике:
+
+- **Все ваши сессии, а не одна** — CloudCLI UI автоматически находит каждую сессию из папки `~/.claude`. Remote Control предоставляет только одну активную сессию, чтобы сделать ее доступной в мобильном приложении Claude.
+- **Ваши настройки остаются вашими** — MCP-серверы, права инструментов и конфигурация проекта, измененные в CloudCLI UI, записываются напрямую в конфиг Claude Code и вступают в силу сразу же, и наоборот.
+- **Поддержка большего числа агентов** — Claude Code, Cursor CLI, Codex и Gemini CLI, а не только Claude Code.
+- **Полноценный UI, а не просто окно чата** — встроены файловый менеджер, Git-интеграция, управление MCP и shell-терминал.
+- **CloudCLI Cloud работает в облаке** — можно закрыть ноутбук, а агент продолжит работу. Не нужно держать терминал открытым и машину в активном состоянии.
+
+
+
+
+Нужно ли отдельно платить за AI-подписку?
+
+Да. CloudCLI предоставляет среду, а не сам AI. Вы используете собственную подписку Claude, Cursor, Codex или Gemini. CloudCLI Cloud стоит от $7/месяц за хостируемую среду сверх этого.
+
+
+
+
+Можно ли пользоваться CloudCLI UI с телефона?
+
+Да. Для self-hosted запустите сервер на своей машине и откройте `[yourip]:port` в любом браузере внутри вашей сети. Для CloudCLI Cloud откройте сервис с любого устройства — без VPN, проброса портов и дополнительной настройки. Нативное приложение тоже разрабатывается.
+
+
+
+
+Повлияют ли изменения, сделанные в UI, на мой локальный Claude Code?
+
+Да, в self-hosted режиме. CloudCLI UI читает и записывает тот же конфиг `~/.claude`, который нативно использует Claude Code. MCP-серверы, добавленные через UI, сразу появляются в Claude Code, и наоборот.
+
+
+
+---
+
+## Сообщество и поддержка
+
+- **[Документация](https://cloudcli.ai/docs)** — установка, настройка, возможности и устранение неполадок
+- **[Discord](https://discord.gg/buxwujPNRE)** — помощь и общение с другими пользователями
+- **[GitHub Issues](https://github.com/siteboon/claudecodeui/issues)** — баг-репорты и запросы новых функций
+- **[Руководство для контрибьюторов](CONTRIBUTING.md)** — как участвовать в развитии проекта
+
+## Лицензия
+
+GNU General Public License v3.0 - подробности в файле [LICENSE](LICENSE).
+
+Этот проект открыт и может свободно использоваться, изменяться и распространяться по лицензии GPL v3.
+
+## Благодарности
+
+### Используется
+- **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** - официальный CLI от Anthropic
+- **[Cursor CLI](https://docs.cursor.com/en/cli/overview)** - официальный CLI от Cursor
+- **[Codex](https://developers.openai.com/codex)** - OpenAI Codex
+- **[Gemini-CLI](https://geminicli.com/)** - Google Gemini CLI
+- **[React](https://react.dev/)** - библиотека пользовательских интерфейсов
+- **[Vite](https://vitejs.dev/)** - быстрый инструмент сборки и dev-сервер
+- **[Tailwind CSS](https://tailwindcss.com/)** - utility-first CSS framework
+- **[CodeMirror](https://codemirror.net/)** - продвинутый редактор кода
+- **[TaskMaster AI](https://github.com/eyaltoledano/claude-task-master)** *(опционально)* - AI-управление проектами и планирование задач
+
+
+### Спонсоры
+- [Siteboon - AI powered website builder](https://siteboon.ai)
+---
+
+
+ Сделано с любовью к сообществу Claude Code, Cursor и Codex.
+
diff --git a/README.zh-CN.md b/README.zh-CN.md
index dccca4b..60e25f6 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -6,7 +6,7 @@
[Claude Code](https://docs.anthropic.com/en/docs/claude-code)、[Cursor CLI](https://docs.cursor.com/en/cli/overview) 和 [Codex](https://developers.openai.com/codex) 的桌面端和移动端界面。您可以在本地或远程使用它来查看 Claude Code、Cursor 或 Codex 中的活跃项目和会话,并从任何地方(移动端或桌面端)对它们进行修改。这为您提供了一个在任何地方都能正常使用的合适界面。
-
+
## 截图
@@ -194,8 +194,8 @@ npm run dev
要使用 Claude Code 的完整功能,您需要手动启用工具:
1. **打开工具设置** - 点击侧边栏中的齿轮图标
-3. **选择性启用** - 仅打开您需要的工具
-4. **应用设置** - 您的偏好设置将保存在本地
+2. **选择性启用** - 仅打开您需要的工具
+3. **应用设置** - 您的偏好设置将保存在本地
@@ -344,4 +344,4 @@ GNU General Public License v3.0 - 详见 [LICENSE](LICENSE) 文件。
为 Claude Code、Cursor 和 Codex 社区精心打造。
-
\ No newline at end of file
+
diff --git a/package-lock.json b/package-lock.json
index 7306b00..43fe1cd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "@siteboon/claude-code-ui",
- "version": "1.23.2",
+ "version": "1.25.2",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@siteboon/claude-code-ui",
- "version": "1.23.2",
+ "version": "1.25.2",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {
diff --git a/package.json b/package.json
index 1a6832e..222f825 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@siteboon/claude-code-ui",
- "version": "1.23.2",
+ "version": "1.25.2",
"description": "A web-based UI for Claude Code CLI",
"type": "module",
"main": "server/index.js",
diff --git a/plugins/starter b/plugins/starter
new file mode 160000
index 0000000..bfa6332
--- /dev/null
+++ b/plugins/starter
@@ -0,0 +1 @@
+Subproject commit bfa63328103ca330a012bc083e4f934adbc2086e
diff --git a/public/screenshots/cli-selection.png b/public/screenshots/cli-selection.png
index 507cfce..2f3abf3 100644
Binary files a/public/screenshots/cli-selection.png and b/public/screenshots/cli-selection.png differ
diff --git a/public/screenshots/mobile-chat.png b/public/screenshots/mobile-chat.png
index 3c8db7a..8935321 100644
Binary files a/public/screenshots/mobile-chat.png and b/public/screenshots/mobile-chat.png differ
diff --git a/server/cursor-cli.js b/server/cursor-cli.js
index ffd20c3..f5fe7db 100644
--- a/server/cursor-cli.js
+++ b/server/cursor-cli.js
@@ -1,84 +1,124 @@
import { spawn } from 'child_process';
import crossSpawn from 'cross-spawn';
-import { promises as fs } from 'fs';
-import path from 'path';
-import os from 'os';
// Use cross-spawn on Windows for better command execution
const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
let activeCursorProcesses = new Map(); // Track active processes by session ID
+const WORKSPACE_TRUST_PATTERNS = [
+ /workspace trust required/i,
+ /do you trust the contents of this directory/i,
+ /working with untrusted contents/i,
+ /pass --trust,\s*--yolo,\s*or -f/i
+];
+
+function isWorkspaceTrustPrompt(text = '') {
+ if (!text || typeof text !== 'string') {
+ return false;
+ }
+
+ return WORKSPACE_TRUST_PATTERNS.some((pattern) => pattern.test(text));
+}
+
async function spawnCursor(command, options = {}, ws) {
return new Promise(async (resolve, reject) => {
- const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, images } = options;
+ const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options;
let capturedSessionId = sessionId; // Track session ID throughout the process
let sessionCreatedSent = false; // Track if we've already sent session-created event
- let messageBuffer = ''; // Buffer for accumulating assistant messages
-
+ let hasRetriedWithTrust = false;
+ let settled = false;
+
// Use tools settings passed from frontend, or defaults
const settings = toolsSettings || {
allowedShellCommands: [],
skipPermissions: false
};
-
+
// Build Cursor CLI command
- const args = [];
-
+ const baseArgs = [];
+
// Build flags allowing both resume and prompt together (reply in existing session)
// Treat presence of sessionId as intention to resume, regardless of resume flag
if (sessionId) {
- args.push('--resume=' + sessionId);
+ baseArgs.push('--resume=' + sessionId);
}
if (command && command.trim()) {
// Provide a prompt (works for both new and resumed sessions)
- args.push('-p', command);
+ baseArgs.push('-p', command);
// Add model flag if specified (only meaningful for new sessions; harmless on resume)
if (!sessionId && model) {
- args.push('--model', model);
+ baseArgs.push('--model', model);
}
// Request streaming JSON when we are providing a prompt
- args.push('--output-format', 'stream-json');
+ baseArgs.push('--output-format', 'stream-json');
}
-
+
// Add skip permissions flag if enabled
if (skipPermissions || settings.skipPermissions) {
- args.push('-f');
- console.log('⚠️ Using -f flag (skip permissions)');
+ baseArgs.push('-f');
+ console.log('Using -f flag (skip permissions)');
}
-
+
// Use cwd (actual project directory) instead of projectPath
const workingDir = cwd || projectPath || process.cwd();
-
- console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
- console.log('Working directory:', workingDir);
- console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
-
- const cursorProcess = spawnFunction('cursor-agent', args, {
- cwd: workingDir,
- stdio: ['pipe', 'pipe', 'pipe'],
- env: { ...process.env } // Inherit all environment variables
- });
-
+
// Store process reference for potential abort
const processKey = capturedSessionId || Date.now().toString();
- activeCursorProcesses.set(processKey, cursorProcess);
-
- // Handle stdout (streaming JSON responses)
- cursorProcess.stdout.on('data', (data) => {
- const rawOutput = data.toString();
- console.log('📤 Cursor CLI stdout:', rawOutput);
-
- const lines = rawOutput.split('\n').filter(line => line.trim());
-
- for (const line of lines) {
+
+ const settleOnce = (callback) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ callback();
+ };
+
+ const runCursorProcess = (args, runReason = 'initial') => {
+ const isTrustRetry = runReason === 'trust-retry';
+ let runSawWorkspaceTrustPrompt = false;
+ let stdoutLineBuffer = '';
+
+ if (isTrustRetry) {
+ console.log('Retrying Cursor CLI with --trust after workspace trust prompt');
+ }
+
+ console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
+ console.log('Working directory:', workingDir);
+ console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
+
+ const cursorProcess = spawnFunction('cursor-agent', args, {
+ cwd: workingDir,
+ stdio: ['pipe', 'pipe', 'pipe'],
+ env: { ...process.env } // Inherit all environment variables
+ });
+
+ activeCursorProcesses.set(processKey, cursorProcess);
+
+ const shouldSuppressForTrustRetry = (text) => {
+ if (hasRetriedWithTrust || args.includes('--trust')) {
+ return false;
+ }
+ if (!isWorkspaceTrustPrompt(text)) {
+ return false;
+ }
+
+ runSawWorkspaceTrustPrompt = true;
+ return true;
+ };
+
+ const processCursorOutputLine = (line) => {
+ if (!line || !line.trim()) {
+ return;
+ }
+
try {
const response = JSON.parse(line);
- console.log('📄 Parsed JSON response:', response);
-
+ console.log('Parsed JSON response:', response);
+
// Handle different message types
switch (response.type) {
case 'system':
@@ -86,14 +126,14 @@ async function spawnCursor(command, options = {}, ws) {
// Capture session ID
if (response.session_id && !capturedSessionId) {
capturedSessionId = response.session_id;
- console.log('📝 Captured session ID:', capturedSessionId);
-
+ console.log('Captured session ID:', capturedSessionId);
+
// Update process key with captured session ID
if (processKey !== capturedSessionId) {
activeCursorProcesses.delete(processKey);
activeCursorProcesses.set(capturedSessionId, cursorProcess);
}
-
+
// Set session ID on writer (for API endpoint compatibility)
if (ws.setSessionId && typeof ws.setSessionId === 'function') {
ws.setSessionId(capturedSessionId);
@@ -110,7 +150,7 @@ async function spawnCursor(command, options = {}, ws) {
});
}
}
-
+
// Send system info to frontend
ws.send({
type: 'cursor-system',
@@ -119,7 +159,7 @@ async function spawnCursor(command, options = {}, ws) {
});
}
break;
-
+
case 'user':
// Forward user message
ws.send({
@@ -128,13 +168,12 @@ async function spawnCursor(command, options = {}, ws) {
sessionId: capturedSessionId || sessionId || null
});
break;
-
+
case 'assistant':
// Accumulate assistant message chunks
if (response.message && response.message.content && response.message.content.length > 0) {
const textContent = response.message.content[0].text;
- messageBuffer += textContent;
-
+
// Send as Claude-compatible format for frontend
ws.send({
type: 'claude-response',
@@ -149,23 +188,14 @@ async function spawnCursor(command, options = {}, ws) {
});
}
break;
-
+
case 'result':
// Session complete
console.log('Cursor session result:', response);
-
- // Send final message if we have buffered content
- if (messageBuffer) {
- ws.send({
- type: 'claude-response',
- data: {
- type: 'content_block_stop'
- },
- sessionId: capturedSessionId || sessionId || null
- });
- }
-
- // Send completion event
+
+ // Do not emit an extra content_block_stop here.
+ // The UI already finalizes the streaming message in cursor-result handling,
+ // and emitting both can produce duplicate assistant messages.
ws.send({
type: 'cursor-result',
sessionId: capturedSessionId || sessionId,
@@ -173,7 +203,7 @@ async function spawnCursor(command, options = {}, ws) {
success: response.subtype === 'success'
});
break;
-
+
default:
// Forward any other message types
ws.send({
@@ -183,7 +213,12 @@ async function spawnCursor(command, options = {}, ws) {
});
}
} catch (parseError) {
- console.log('📄 Non-JSON response:', line);
+ console.log('Non-JSON response:', line);
+
+ if (shouldSuppressForTrustRetry(line)) {
+ return;
+ }
+
// If not JSON, send as raw text
ws.send({
type: 'cursor-output',
@@ -191,67 +226,106 @@ async function spawnCursor(command, options = {}, ws) {
sessionId: capturedSessionId || sessionId || null
});
}
- }
- });
-
- // Handle stderr
- cursorProcess.stderr.on('data', (data) => {
- console.error('Cursor CLI stderr:', data.toString());
- ws.send({
- type: 'cursor-error',
- error: data.toString(),
- sessionId: capturedSessionId || sessionId || null
- });
- });
-
- // Handle process completion
- cursorProcess.on('close', async (code) => {
- console.log(`Cursor CLI process exited with code ${code}`);
-
- // Clean up process reference
- const finalSessionId = capturedSessionId || sessionId || processKey;
- activeCursorProcesses.delete(finalSessionId);
+ };
- ws.send({
- type: 'claude-complete',
- sessionId: finalSessionId,
- exitCode: code,
- isNewSession: !sessionId && !!command // Flag to indicate this was a new session
- });
-
- if (code === 0) {
- resolve();
- } else {
- reject(new Error(`Cursor CLI exited with code ${code}`));
- }
- });
-
- // Handle process errors
- cursorProcess.on('error', (error) => {
- console.error('Cursor CLI process error:', error);
-
- // Clean up process reference on error
- const finalSessionId = capturedSessionId || sessionId || processKey;
- activeCursorProcesses.delete(finalSessionId);
+ // Handle stdout (streaming JSON responses)
+ cursorProcess.stdout.on('data', (data) => {
+ const rawOutput = data.toString();
+ console.log('Cursor CLI stdout:', rawOutput);
- ws.send({
- type: 'cursor-error',
- error: error.message,
- sessionId: capturedSessionId || sessionId || null
+ // Stream chunks can split JSON objects across packets; keep trailing partial line.
+ stdoutLineBuffer += rawOutput;
+ const completeLines = stdoutLineBuffer.split(/\r?\n/);
+ stdoutLineBuffer = completeLines.pop() || '';
+
+ completeLines.forEach((line) => {
+ processCursorOutputLine(line.trim());
+ });
});
- reject(error);
- });
-
- // Close stdin since Cursor doesn't need interactive input
- cursorProcess.stdin.end();
+ // Handle stderr
+ cursorProcess.stderr.on('data', (data) => {
+ const stderrText = data.toString();
+ console.error('Cursor CLI stderr:', stderrText);
+
+ if (shouldSuppressForTrustRetry(stderrText)) {
+ return;
+ }
+
+ ws.send({
+ type: 'cursor-error',
+ error: stderrText,
+ sessionId: capturedSessionId || sessionId || null
+ });
+ });
+
+ // Handle process completion
+ cursorProcess.on('close', async (code) => {
+ console.log(`Cursor CLI process exited with code ${code}`);
+
+ const finalSessionId = capturedSessionId || sessionId || processKey;
+ activeCursorProcesses.delete(finalSessionId);
+
+ // Flush any final unterminated stdout line before completion handling.
+ if (stdoutLineBuffer.trim()) {
+ processCursorOutputLine(stdoutLineBuffer.trim());
+ stdoutLineBuffer = '';
+ }
+
+ if (
+ runSawWorkspaceTrustPrompt &&
+ code !== 0 &&
+ !hasRetriedWithTrust &&
+ !args.includes('--trust')
+ ) {
+ hasRetriedWithTrust = true;
+ runCursorProcess([...args, '--trust'], 'trust-retry');
+ return;
+ }
+
+ ws.send({
+ type: 'claude-complete',
+ sessionId: finalSessionId,
+ exitCode: code,
+ isNewSession: !sessionId && !!command // Flag to indicate this was a new session
+ });
+
+ if (code === 0) {
+ settleOnce(() => resolve());
+ } else {
+ settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`)));
+ }
+ });
+
+ // Handle process errors
+ cursorProcess.on('error', (error) => {
+ console.error('Cursor CLI process error:', error);
+
+ // Clean up process reference on error
+ const finalSessionId = capturedSessionId || sessionId || processKey;
+ activeCursorProcesses.delete(finalSessionId);
+
+ ws.send({
+ type: 'cursor-error',
+ error: error.message,
+ sessionId: capturedSessionId || sessionId || null
+ });
+
+ settleOnce(() => reject(error));
+ });
+
+ // Close stdin since Cursor doesn't need interactive input
+ cursorProcess.stdin.end();
+ };
+
+ runCursorProcess(baseArgs, 'initial');
});
}
function abortCursorSession(sessionId) {
const process = activeCursorProcesses.get(sessionId);
if (process) {
- console.log(`🛑 Aborting Cursor session: ${sessionId}`);
+ console.log(`Aborting Cursor session: ${sessionId}`);
process.kill('SIGTERM');
activeCursorProcesses.delete(sessionId);
return true;
diff --git a/server/database/db.js b/server/database/db.js
index e3d25cf..9ab0ad7 100644
--- a/server/database/db.js
+++ b/server/database/db.js
@@ -59,6 +59,15 @@ if (DB_PATH !== LEGACY_DB_PATH && !fs.existsSync(DB_PATH) && fs.existsSync(LEGAC
// Create database connection
const db = new Database(DB_PATH);
+// app_config must exist before any other module imports (auth.js reads the JWT secret at load time).
+// runMigrations() also creates this table, but it runs too late for existing installations
+// where auth.js is imported before initializeDatabase() is called.
+db.exec(`CREATE TABLE IF NOT EXISTS app_config (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+)`);
+
// Show app installation path prominently
const appInstallPath = path.join(__dirname, '../..');
console.log('');
@@ -120,6 +129,12 @@ const runMigrations = () => {
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)
`);
+ // Create app_config table if it doesn't exist (for existing installations)
+ db.exec(`CREATE TABLE IF NOT EXISTS app_config (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+ )`);
// Create session_names table if it doesn't exist (for existing installations)
db.exec(`CREATE TABLE IF NOT EXISTS session_names (
@@ -554,6 +569,33 @@ function applyCustomSessionNames(sessions, provider) {
}
}
+// App config database operations
+const appConfigDb = {
+ get: (key) => {
+ try {
+ const row = db.prepare('SELECT value FROM app_config WHERE key = ?').get(key);
+ return row?.value || null;
+ } catch (err) {
+ return null;
+ }
+ },
+
+ set: (key, value) => {
+ db.prepare(
+ 'INSERT INTO app_config (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
+ ).run(key, value);
+ },
+
+ getOrCreateJwtSecret: () => {
+ let secret = appConfigDb.get('jwt_secret');
+ if (!secret) {
+ secret = crypto.randomBytes(64).toString('hex');
+ appConfigDb.set('jwt_secret', secret);
+ }
+ return secret;
+ }
+};
+
// Backward compatibility - keep old names pointing to new system
const githubTokensDb = {
createGithubToken: (userId, tokenName, githubToken, description = null) => {
@@ -583,5 +625,6 @@ export {
pushSubscriptionsDb,
sessionNamesDb,
applyCustomSessionNames,
+ appConfigDb,
githubTokensDb // Backward compatibility
};
diff --git a/server/database/init.sql b/server/database/init.sql
index 73ea517..9835151 100644
--- a/server/database/init.sql
+++ b/server/database/init.sql
@@ -90,3 +90,10 @@ CREATE TABLE IF NOT EXISTS session_names (
);
CREATE INDEX IF NOT EXISTS idx_session_names_lookup ON session_names(session_id, provider);
+
+-- App configuration table (auto-generated secrets, settings, etc.)
+CREATE TABLE IF NOT EXISTS app_config (
+ key TEXT PRIMARY KEY,
+ value TEXT NOT NULL,
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
+);
diff --git a/server/index.js b/server/index.js
index d0625d9..27aae75 100755
--- a/server/index.js
+++ b/server/index.js
@@ -64,6 +64,8 @@ import cliAuthRoutes from './routes/cli-auth.js';
import userRoutes from './routes/user.js';
import codexRoutes from './routes/codex.js';
import geminiRoutes from './routes/gemini.js';
+import pluginsRoutes from './routes/plugins.js';
+import { startEnabledPluginServers, stopAllPlugins } from './utils/plugin-process-manager.js';
import { initializeDatabase, sessionNamesDb, applyCustomSessionNames } from './database/db.js';
import { configureWebPush } from './services/vapid-keys.js';
import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
@@ -325,7 +327,7 @@ const wss = new WebSocketServer({
// Make WebSocket server available to routes
app.locals.wss = wss;
-app.use(cors());
+app.use(cors({ exposedHeaders: ['X-Refreshed-Token'] }));
app.use(express.json({
limit: '50mb',
type: (req) => {
@@ -390,6 +392,9 @@ app.use('/api/codex', authenticateToken, codexRoutes);
// Gemini API Routes (protected)
app.use('/api/gemini', authenticateToken, geminiRoutes);
+// Plugins API Routes (protected)
+app.use('/api/plugins', authenticateToken, pluginsRoutes);
+
// Agent API Routes (uses API key authentication)
app.use('/api/agent', agentRoutes);
@@ -1696,50 +1701,49 @@ function handleShellConnection(ws) {
}));
try {
- // Prepare the shell command adapted to the platform and provider
+ // Validate projectPath — resolve to absolute and verify it exists
+ const resolvedProjectPath = path.resolve(projectPath);
+ try {
+ const stats = fs.statSync(resolvedProjectPath);
+ if (!stats.isDirectory()) {
+ throw new Error('Not a directory');
+ }
+ } catch (pathErr) {
+ ws.send(JSON.stringify({ type: 'error', message: 'Invalid project path' }));
+ return;
+ }
+
+ // Validate sessionId — only allow safe characters
+ const safeSessionIdPattern = /^[a-zA-Z0-9_.\-:]+$/;
+ if (sessionId && !safeSessionIdPattern.test(sessionId)) {
+ ws.send(JSON.stringify({ type: 'error', message: 'Invalid session ID' }));
+ return;
+ }
+
+ // Build shell command — use cwd for project path (never interpolate into shell string)
let shellCommand;
if (isPlainShell) {
- // Plain shell mode - just run the initial command in the project directory
- if (os.platform() === 'win32') {
- shellCommand = `Set-Location -Path "${projectPath}"; ${initialCommand}`;
- } else {
- shellCommand = `cd "${projectPath}" && ${initialCommand}`;
- }
+ // Plain shell mode - run the initial command in the project directory
+ shellCommand = initialCommand;
} else if (provider === 'cursor') {
- // Use cursor-agent command
- if (os.platform() === 'win32') {
- if (hasSession && sessionId) {
- shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent --resume="${sessionId}"`;
- } else {
- shellCommand = `Set-Location -Path "${projectPath}"; cursor-agent`;
- }
+ if (hasSession && sessionId) {
+ shellCommand = `cursor-agent --resume="${sessionId}"`;
} else {
- if (hasSession && sessionId) {
- shellCommand = `cd "${projectPath}" && cursor-agent --resume="${sessionId}"`;
- } else {
- shellCommand = `cd "${projectPath}" && cursor-agent`;
- }
+ shellCommand = 'cursor-agent';
}
-
} else if (provider === 'codex') {
- // Use codex command
- if (os.platform() === 'win32') {
- if (hasSession && sessionId) {
- // Try to resume session, but with fallback to a new session if it fails
- shellCommand = `Set-Location -Path "${projectPath}"; codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
+ // Use codex command; attempt to resume and fall back to a new session when the resume fails.
+ if (hasSession && sessionId) {
+ if (os.platform() === 'win32') {
+ // PowerShell syntax for fallback
+ shellCommand = `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
} else {
- shellCommand = `Set-Location -Path "${projectPath}"; codex`;
+ shellCommand = `codex resume "${sessionId}" || codex`;
}
} else {
- if (hasSession && sessionId) {
- // Try to resume session, but with fallback to a new session if it fails
- shellCommand = `cd "${projectPath}" && codex resume "${sessionId}" || codex`;
- } else {
- shellCommand = `cd "${projectPath}" && codex`;
- }
+ shellCommand = 'codex';
}
} else if (provider === 'gemini') {
- // Use gemini command
const command = initialCommand || 'gemini';
let resumeId = sessionId;
if (hasSession && sessionId) {
@@ -1750,41 +1754,32 @@ function handleShellConnection(ws) {
const sess = sessionManager.getSession(sessionId);
if (sess && sess.cliSessionId) {
resumeId = sess.cliSessionId;
+ // Validate the looked-up CLI session ID too
+ if (!safeSessionIdPattern.test(resumeId)) {
+ resumeId = null;
+ }
}
} catch (err) {
console.error('Failed to get Gemini CLI session ID:', err);
}
}
- if (os.platform() === 'win32') {
- if (hasSession && resumeId) {
- shellCommand = `Set-Location -Path "${projectPath}"; ${command} --resume "${resumeId}"`;
- } else {
- shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
- }
+ if (hasSession && resumeId) {
+ shellCommand = `${command} --resume "${resumeId}"`;
} else {
- if (hasSession && resumeId) {
- shellCommand = `cd "${projectPath}" && ${command} --resume "${resumeId}"`;
- } else {
- shellCommand = `cd "${projectPath}" && ${command}`;
- }
+ shellCommand = command;
}
} else {
- // Use claude command (default) or initialCommand if provided
+ // Claude (default provider)
const command = initialCommand || 'claude';
- if (os.platform() === 'win32') {
- if (hasSession && sessionId) {
- // Try to resume session, but with fallback to new session if it fails
- shellCommand = `Set-Location -Path "${projectPath}"; claude --resume ${sessionId}; if ($LASTEXITCODE -ne 0) { claude }`;
+ if (hasSession && sessionId) {
+ if (os.platform() === 'win32') {
+ shellCommand = `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
} else {
- shellCommand = `Set-Location -Path "${projectPath}"; ${command}`;
+ shellCommand = `claude --resume "${sessionId}" || claude`;
}
} else {
- if (hasSession && sessionId) {
- shellCommand = `cd "${projectPath}" && claude --resume ${sessionId} || claude`;
- } else {
- shellCommand = `cd "${projectPath}" && ${command}`;
- }
+ shellCommand = command;
}
}
@@ -1803,7 +1798,7 @@ function handleShellConnection(ws) {
name: 'xterm-256color',
cols: termCols,
rows: termRows,
- cwd: os.homedir(),
+ cwd: resolvedProjectPath,
env: {
...process.env,
TERM: 'xterm-256color',
@@ -2537,7 +2532,20 @@ async function startServer() {
// Start watching the projects folder for changes
await setupProjectsWatcher();
+
+ // Start server-side plugin processes for enabled plugins
+ startEnabledPluginServers().catch(err => {
+ console.error('[Plugins] Error during startup:', err.message);
+ });
});
+
+ // Clean up plugin processes on shutdown
+ const shutdownPlugins = async () => {
+ await stopAllPlugins();
+ process.exit(0);
+ };
+ process.on('SIGTERM', () => void shutdownPlugins());
+ process.on('SIGINT', () => void shutdownPlugins());
} catch (error) {
console.error('[ERROR] Failed to start server:', error);
process.exit(1);
diff --git a/server/middleware/auth.js b/server/middleware/auth.js
index 3426fc2..7374979 100644
--- a/server/middleware/auth.js
+++ b/server/middleware/auth.js
@@ -1,9 +1,9 @@
import jwt from 'jsonwebtoken';
-import { userDb } from '../database/db.js';
+import { userDb, appConfigDb } from '../database/db.js';
import { IS_PLATFORM } from '../constants/config.js';
-// Get JWT secret from environment or use default (for development)
-const JWT_SECRET = process.env.JWT_SECRET || 'claude-ui-dev-secret-change-in-production';
+// Use env var if set, otherwise auto-generate a unique secret per installation
+const JWT_SECRET = process.env.JWT_SECRET || appConfigDb.getOrCreateJwtSecret();
// Optional API key middleware
const validateApiKey = (req, res, next) => {
@@ -58,6 +58,16 @@ const authenticateToken = async (req, res, next) => {
return res.status(401).json({ error: 'Invalid token. User not found.' });
}
+ // Auto-refresh: if token is past halfway through its lifetime, issue a new one
+ if (decoded.exp && decoded.iat) {
+ const now = Math.floor(Date.now() / 1000);
+ const halfLife = (decoded.exp - decoded.iat) / 2;
+ if (now > decoded.iat + halfLife) {
+ const newToken = generateToken(user);
+ res.setHeader('X-Refreshed-Token', newToken);
+ }
+ }
+
req.user = user;
next();
} catch (error) {
@@ -66,15 +76,15 @@ const authenticateToken = async (req, res, next) => {
}
};
-// Generate JWT token (never expires)
+// Generate JWT token
const generateToken = (user) => {
return jwt.sign(
- {
- userId: user.id,
- username: user.username
+ {
+ userId: user.id,
+ username: user.username
},
- JWT_SECRET
- // No expiration - token lasts forever
+ JWT_SECRET,
+ { expiresIn: '7d' }
);
};
@@ -101,10 +111,12 @@ const authenticateWebSocket = (token) => {
try {
const decoded = jwt.verify(token, JWT_SECRET);
- return {
- ...decoded,
- id: decoded.userId
- };
+ // Verify user actually exists in database (matches REST authenticateToken behavior)
+ const user = userDb.getUserById(decoded.userId);
+ if (!user) {
+ return null;
+ }
+ return { userId: user.id, username: user.username };
} catch (error) {
console.error('WebSocket token verification error:', error);
return null;
diff --git a/server/routes/commands.js b/server/routes/commands.js
index 5446734..388a8f7 100644
--- a/server/routes/commands.js
+++ b/server/routes/commands.js
@@ -3,8 +3,8 @@ import { promises as fs } from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import os from 'os';
-import matter from 'gray-matter';
import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
+import { parseFrontmatter } from '../utils/frontmatter.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -38,7 +38,7 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
// Parse markdown file for metadata
try {
const content = await fs.readFile(fullPath, 'utf8');
- const { data: frontmatter, content: commandContent } = matter(content);
+ const { data: frontmatter, content: commandContent } = parseFrontmatter(content);
// Calculate relative path from baseDir for command name
const relativePath = path.relative(baseDir, fullPath);
@@ -475,7 +475,7 @@ router.post('/load', async (req, res) => {
// Read and parse the command file
const content = await fs.readFile(commandPath, 'utf8');
- const { data: metadata, content: commandContent } = matter(content);
+ const { data: metadata, content: commandContent } = parseFrontmatter(content);
res.json({
path: commandPath,
@@ -560,7 +560,7 @@ router.post('/execute', async (req, res) => {
}
}
const content = await fs.readFile(commandPath, 'utf8');
- const { data: metadata, content: commandContent } = matter(content);
+ const { data: metadata, content: commandContent } = parseFrontmatter(content);
// Basic argument replacement (will be enhanced in command parser utility)
let processedContent = commandContent;
diff --git a/server/routes/git.js b/server/routes/git.js
index e6fecee..701c3be 100755
--- a/server/routes/git.js
+++ b/server/routes/git.js
@@ -1,6 +1,5 @@
import express from 'express';
-import { exec, spawn } from 'child_process';
-import { promisify } from 'util';
+import { spawn } from 'child_process';
import path from 'path';
import { promises as fs } from 'fs';
import { extractProjectDirectory } from '../projects.js';
@@ -8,7 +7,7 @@ import { queryClaudeSDK } from '../claude-sdk.js';
import { spawnCursor } from '../cursor-cli.js';
const router = express.Router();
-const execAsync = promisify(exec);
+const COMMIT_DIFF_CHARACTER_LIMIT = 500_000;
function spawnAsync(command, args, options = {}) {
return new Promise((resolve, reject) => {
@@ -47,15 +46,71 @@ function spawnAsync(command, args, options = {}) {
});
}
+// Input validation helpers (defense-in-depth)
+function validateCommitRef(commit) {
+ // Allow hex hashes, HEAD, HEAD~N, HEAD^N, tag names, branch names
+ if (!/^[a-zA-Z0-9._~^{}@\/-]+$/.test(commit)) {
+ throw new Error('Invalid commit reference');
+ }
+ return commit;
+}
+
+function validateBranchName(branch) {
+ if (!/^[a-zA-Z0-9._\/-]+$/.test(branch)) {
+ throw new Error('Invalid branch name');
+ }
+ return branch;
+}
+
+function validateFilePath(file, projectPath) {
+ if (!file || file.includes('\0')) {
+ throw new Error('Invalid file path');
+ }
+ // Prevent path traversal: resolve the file relative to the project root
+ // and ensure the result stays within the project directory
+ if (projectPath) {
+ const resolved = path.resolve(projectPath, file);
+ const normalizedRoot = path.resolve(projectPath) + path.sep;
+ if (!resolved.startsWith(normalizedRoot) && resolved !== path.resolve(projectPath)) {
+ throw new Error('Invalid file path: path traversal detected');
+ }
+ }
+ return file;
+}
+
+function validateRemoteName(remote) {
+ if (!/^[a-zA-Z0-9._-]+$/.test(remote)) {
+ throw new Error('Invalid remote name');
+ }
+ return remote;
+}
+
+function validateProjectPath(projectPath) {
+ if (!projectPath || projectPath.includes('\0')) {
+ throw new Error('Invalid project path');
+ }
+ const resolved = path.resolve(projectPath);
+ // Must be an absolute path after resolution
+ if (!path.isAbsolute(resolved)) {
+ throw new Error('Invalid project path: must be absolute');
+ }
+ // Block obviously dangerous paths
+ if (resolved === '/' || resolved === path.sep) {
+ throw new Error('Invalid project path: root directory not allowed');
+ }
+ return resolved;
+}
+
// Helper function to get the actual project path from the encoded project name
async function getActualProjectPath(projectName) {
+ let projectPath;
try {
- return await extractProjectDirectory(projectName);
+ projectPath = await extractProjectDirectory(projectName);
} catch (error) {
console.error(`Error extracting project directory for ${projectName}:`, error);
- // Fallback to the old method
- return projectName.replace(/-/g, '/');
+ throw new Error(`Unable to resolve project path for "${projectName}"`);
}
+ return validateProjectPath(projectPath);
}
// Helper function to strip git diff headers
@@ -98,19 +153,140 @@ async function validateGitRepository(projectPath) {
try {
// Allow any directory that is inside a work tree (repo root or nested folder).
- const { stdout: insideWorkTreeOutput } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: projectPath });
+ const { stdout: insideWorkTreeOutput } = await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath });
const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true';
if (!isInsideWorkTree) {
throw new Error('Not inside a git work tree');
}
// Ensure git can resolve the repository root for this directory.
- await execAsync('git rev-parse --show-toplevel', { cwd: projectPath });
+ await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
} catch {
throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.');
}
}
+function getGitErrorDetails(error) {
+ return `${error?.message || ''} ${error?.stderr || ''} ${error?.stdout || ''}`;
+}
+
+function isMissingHeadRevisionError(error) {
+ const errorDetails = getGitErrorDetails(error).toLowerCase();
+ return errorDetails.includes('unknown revision')
+ || errorDetails.includes('ambiguous argument')
+ || errorDetails.includes('needed a single revision')
+ || errorDetails.includes('bad revision');
+}
+
+async function getCurrentBranchName(projectPath) {
+ try {
+ // symbolic-ref works even when the repository has no commits.
+ const { stdout } = await spawnAsync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: projectPath });
+ const branchName = stdout.trim();
+ if (branchName) {
+ return branchName;
+ }
+ } catch (error) {
+ // Fall back to rev-parse for detached HEAD and older git edge cases.
+ }
+
+ const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath });
+ return stdout.trim();
+}
+
+async function repositoryHasCommits(projectPath) {
+ try {
+ await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
+ return true;
+ } catch (error) {
+ if (isMissingHeadRevisionError(error)) {
+ return false;
+ }
+ throw error;
+ }
+}
+
+async function getRepositoryRootPath(projectPath) {
+ const { stdout } = await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath });
+ return stdout.trim();
+}
+
+function normalizeRepositoryRelativeFilePath(filePath) {
+ return String(filePath)
+ .replace(/\\/g, '/')
+ .replace(/^\.\/+/, '')
+ .replace(/^\/+/, '')
+ .trim();
+}
+
+function parseStatusFilePaths(statusOutput) {
+ return statusOutput
+ .split('\n')
+ .map((line) => line.trimEnd())
+ .filter((line) => line.trim())
+ .map((line) => {
+ const statusPath = line.substring(3);
+ const renamedFilePath = statusPath.split(' -> ')[1];
+ return normalizeRepositoryRelativeFilePath(renamedFilePath || statusPath);
+ })
+ .filter(Boolean);
+}
+
+function buildFilePathCandidates(projectPath, repositoryRootPath, filePath) {
+ const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
+ const projectRelativePath = normalizeRepositoryRelativeFilePath(path.relative(repositoryRootPath, projectPath));
+ const candidates = [normalizedFilePath];
+
+ if (
+ projectRelativePath
+ && projectRelativePath !== '.'
+ && !normalizedFilePath.startsWith(`${projectRelativePath}/`)
+ ) {
+ candidates.push(`${projectRelativePath}/${normalizedFilePath}`);
+ }
+
+ return Array.from(new Set(candidates.filter(Boolean)));
+}
+
+async function resolveRepositoryFilePath(projectPath, filePath) {
+ validateFilePath(filePath);
+
+ const repositoryRootPath = await getRepositoryRootPath(projectPath);
+ const candidateFilePaths = buildFilePathCandidates(projectPath, repositoryRootPath, filePath);
+
+ for (const candidateFilePath of candidateFilePaths) {
+ const { stdout } = await spawnAsync('git', ['status', '--porcelain', '--', candidateFilePath], { cwd: repositoryRootPath });
+ if (stdout.trim()) {
+ return {
+ repositoryRootPath,
+ repositoryRelativeFilePath: candidateFilePath,
+ };
+ }
+ }
+
+ // If the caller sent a bare filename (e.g. "hello.ts"), recover it from changed files.
+ const normalizedFilePath = normalizeRepositoryRelativeFilePath(filePath);
+ if (!normalizedFilePath.includes('/')) {
+ const { stdout: repositoryStatusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: repositoryRootPath });
+ const changedFilePaths = parseStatusFilePaths(repositoryStatusOutput);
+ const suffixMatches = changedFilePaths.filter(
+ (changedFilePath) => changedFilePath === normalizedFilePath || changedFilePath.endsWith(`/${normalizedFilePath}`),
+ );
+
+ if (suffixMatches.length === 1) {
+ return {
+ repositoryRootPath,
+ repositoryRelativeFilePath: suffixMatches[0],
+ };
+ }
+ }
+
+ return {
+ repositoryRootPath,
+ repositoryRelativeFilePath: candidateFilePaths[0],
+ };
+}
+
// Get git status for a project
router.get('/status', async (req, res) => {
const { project } = req.query;
@@ -125,24 +301,11 @@ router.get('/status', async (req, res) => {
// Validate git repository
await validateGitRepository(projectPath);
- // Get current branch - handle case where there are no commits yet
- let branch = 'main';
- let hasCommits = true;
- try {
- const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
- branch = branchOutput.trim();
- } catch (error) {
- // No HEAD exists - repository has no commits yet
- if (error.message.includes('unknown revision') || error.message.includes('ambiguous argument')) {
- hasCommits = false;
- branch = 'main';
- } else {
- throw error;
- }
- }
+ const branch = await getCurrentBranchName(projectPath);
+ const hasCommits = await repositoryHasCommits(projectPath);
// Get git status
- const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: projectPath });
+ const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath });
const modified = [];
const added = [];
@@ -200,44 +363,65 @@ router.get('/diff', async (req, res) => {
// Validate git repository
await validateGitRepository(projectPath);
-
+
+ const {
+ repositoryRootPath,
+ repositoryRelativeFilePath,
+ } = await resolveRepositoryFilePath(projectPath, file);
+
// Check if file is untracked or deleted
- const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
+ const { stdout: statusOutput } = await spawnAsync(
+ 'git',
+ ['status', '--porcelain', '--', repositoryRelativeFilePath],
+ { cwd: repositoryRootPath },
+ );
const isUntracked = statusOutput.startsWith('??');
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
let diff;
if (isUntracked) {
// For untracked files, show the entire file content as additions
- const filePath = path.join(projectPath, file);
+ const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
// For directories, show a simple message
- diff = `Directory: ${file}\n(Cannot show diff for directories)`;
+ diff = `Directory: ${repositoryRelativeFilePath}\n(Cannot show diff for directories)`;
} else {
const fileContent = await fs.readFile(filePath, 'utf-8');
const lines = fileContent.split('\n');
- diff = `--- /dev/null\n+++ b/${file}\n@@ -0,0 +1,${lines.length} @@\n` +
+ diff = `--- /dev/null\n+++ b/${repositoryRelativeFilePath}\n@@ -0,0 +1,${lines.length} @@\n` +
lines.map(line => `+${line}`).join('\n');
}
} else if (isDeleted) {
// For deleted files, show the entire file content from HEAD as deletions
- const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
+ const { stdout: fileContent } = await spawnAsync(
+ 'git',
+ ['show', `HEAD:${repositoryRelativeFilePath}`],
+ { cwd: repositoryRootPath },
+ );
const lines = fileContent.split('\n');
- diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
+ diff = `--- a/${repositoryRelativeFilePath}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` +
lines.map(line => `-${line}`).join('\n');
} else {
// Get diff for tracked files
// First check for unstaged changes (working tree vs index)
- const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: projectPath });
+ const { stdout: unstagedDiff } = await spawnAsync(
+ 'git',
+ ['diff', '--', repositoryRelativeFilePath],
+ { cwd: repositoryRootPath },
+ );
if (unstagedDiff) {
// Show unstaged changes if they exist
diff = stripDiffHeaders(unstagedDiff);
} else {
// If no unstaged changes, check for staged changes (index vs HEAD)
- const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath });
+ const { stdout: stagedDiff } = await spawnAsync(
+ 'git',
+ ['diff', '--cached', '--', repositoryRelativeFilePath],
+ { cwd: repositoryRootPath },
+ );
diff = stripDiffHeaders(stagedDiff) || '';
}
}
@@ -263,8 +447,17 @@ router.get('/file-with-diff', async (req, res) => {
// Validate git repository
await validateGitRepository(projectPath);
+ const {
+ repositoryRootPath,
+ repositoryRelativeFilePath,
+ } = await resolveRepositoryFilePath(projectPath, file);
+
// Check file status
- const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
+ const { stdout: statusOutput } = await spawnAsync(
+ 'git',
+ ['status', '--porcelain', '--', repositoryRelativeFilePath],
+ { cwd: repositoryRootPath },
+ );
const isUntracked = statusOutput.startsWith('??');
const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D');
@@ -273,12 +466,16 @@ router.get('/file-with-diff', async (req, res) => {
if (isDeleted) {
// For deleted files, get content from HEAD
- const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
+ const { stdout: headContent } = await spawnAsync(
+ 'git',
+ ['show', `HEAD:${repositoryRelativeFilePath}`],
+ { cwd: repositoryRootPath },
+ );
oldContent = headContent;
currentContent = headContent; // Show the deleted content in editor
} else {
// Get current file content
- const filePath = path.join(projectPath, file);
+ const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
@@ -291,7 +488,11 @@ router.get('/file-with-diff', async (req, res) => {
if (!isUntracked) {
// Get the old content from HEAD for tracked files
try {
- const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath });
+ const { stdout: headContent } = await spawnAsync(
+ 'git',
+ ['show', `HEAD:${repositoryRelativeFilePath}`],
+ { cwd: repositoryRootPath },
+ );
oldContent = headContent;
} catch (error) {
// File might be newly added to git (staged but not committed)
@@ -328,17 +529,17 @@ router.post('/initial-commit', async (req, res) => {
// Check if there are already commits
try {
- await execAsync('git rev-parse HEAD', { cwd: projectPath });
+ await spawnAsync('git', ['rev-parse', 'HEAD'], { cwd: projectPath });
return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' });
} catch (error) {
// No HEAD - this is good, we can create initial commit
}
// Add all files
- await execAsync('git add .', { cwd: projectPath });
+ await spawnAsync('git', ['add', '.'], { cwd: projectPath });
// Create initial commit
- const { stdout } = await execAsync('git commit -m "Initial commit"', { cwd: projectPath });
+ const { stdout } = await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectPath });
res.json({ success: true, output: stdout, message: 'Initial commit created successfully' });
} catch (error) {
@@ -369,14 +570,16 @@ router.post('/commit', async (req, res) => {
// Validate git repository
await validateGitRepository(projectPath);
+ const repositoryRootPath = await getRepositoryRootPath(projectPath);
// Stage selected files
for (const file of files) {
- await execAsync(`git add "${file}"`, { cwd: projectPath });
+ const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
+ await spawnAsync('git', ['add', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
}
-
+
// Commit with message
- const { stdout } = await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: projectPath });
+ const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: repositoryRootPath });
res.json({ success: true, output: stdout });
} catch (error) {
@@ -385,6 +588,53 @@ router.post('/commit', async (req, res) => {
}
});
+// Revert latest local commit (keeps changes staged)
+router.post('/revert-local-commit', async (req, res) => {
+ const { project } = req.body;
+
+ if (!project) {
+ return res.status(400).json({ error: 'Project name is required' });
+ }
+
+ try {
+ const projectPath = await getActualProjectPath(project);
+ await validateGitRepository(projectPath);
+
+ try {
+ await spawnAsync('git', ['rev-parse', '--verify', 'HEAD'], { cwd: projectPath });
+ } catch (error) {
+ return res.status(400).json({
+ error: 'No local commit to revert',
+ details: 'This repository has no commit yet.',
+ });
+ }
+
+ try {
+ // Soft reset rewinds one commit while preserving all file changes in the index.
+ await spawnAsync('git', ['reset', '--soft', 'HEAD~1'], { cwd: projectPath });
+ } catch (error) {
+ const errorDetails = `${error.stderr || ''} ${error.message || ''}`;
+ const isInitialCommit = errorDetails.includes('HEAD~1') &&
+ (errorDetails.includes('unknown revision') || errorDetails.includes('ambiguous argument'));
+
+ if (!isInitialCommit) {
+ throw error;
+ }
+
+ // Initial commit has no parent; deleting HEAD uncommits it and keeps files staged.
+ await spawnAsync('git', ['update-ref', '-d', 'HEAD'], { cwd: projectPath });
+ }
+
+ res.json({
+ success: true,
+ output: 'Latest local commit reverted successfully. Changes were kept staged.',
+ });
+ } catch (error) {
+ console.error('Git revert local commit error:', error);
+ res.status(500).json({ error: error.message });
+ }
+});
+
// Get list of branches
router.get('/branches', async (req, res) => {
const { project } = req.query;
@@ -400,7 +650,7 @@ router.get('/branches', async (req, res) => {
await validateGitRepository(projectPath);
// Get all branches
- const { stdout } = await execAsync('git branch -a', { cwd: projectPath });
+ const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath });
// Parse branches
const branches = stdout
@@ -439,7 +689,8 @@ router.post('/checkout', async (req, res) => {
const projectPath = await getActualProjectPath(project);
// Checkout the branch
- const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath });
+ validateBranchName(branch);
+ const { stdout } = await spawnAsync('git', ['checkout', branch], { cwd: projectPath });
res.json({ success: true, output: stdout });
} catch (error) {
@@ -460,7 +711,8 @@ router.post('/create-branch', async (req, res) => {
const projectPath = await getActualProjectPath(project);
// Create and checkout new branch
- const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath });
+ validateBranchName(branch);
+ const { stdout } = await spawnAsync('git', ['checkout', '-b', branch], { cwd: projectPath });
res.json({ success: true, output: stdout });
} catch (error) {
@@ -509,8 +761,8 @@ router.get('/commits', async (req, res) => {
// Get stats for each commit
for (const commit of commits) {
try {
- const { stdout: stats } = await execAsync(
- `git show --stat --format='' ${commit.hash}`,
+ const { stdout: stats } = await spawnAsync(
+ 'git', ['show', '--stat', '--format=', commit.hash],
{ cwd: projectPath }
);
commit.stats = stats.trim().split('\n').pop(); // Get the summary line
@@ -536,14 +788,22 @@ router.get('/commit-diff', async (req, res) => {
try {
const projectPath = await getActualProjectPath(project);
-
+
+ // Validate commit reference (defense-in-depth)
+ validateCommitRef(commit);
+
// Get diff for the commit
- const { stdout } = await execAsync(
- `git show ${commit}`,
+ const { stdout } = await spawnAsync(
+ 'git', ['show', commit],
{ cwd: projectPath }
);
-
- res.json({ diff: stdout });
+
+ const isTruncated = stdout.length > COMMIT_DIFF_CHARACTER_LIMIT;
+ const diff = isTruncated
+ ? `${stdout.slice(0, COMMIT_DIFF_CHARACTER_LIMIT)}\n\n... Diff truncated to keep the UI responsive ...`
+ : stdout;
+
+ res.json({ diff, isTruncated });
} catch (error) {
console.error('Git commit diff error:', error);
res.json({ error: error.message });
@@ -565,17 +825,20 @@ router.post('/generate-commit-message', async (req, res) => {
try {
const projectPath = await getActualProjectPath(project);
+ await validateGitRepository(projectPath);
+ const repositoryRootPath = await getRepositoryRootPath(projectPath);
// Get diff for selected files
let diffContext = '';
for (const file of files) {
try {
- const { stdout } = await execAsync(
- `git diff HEAD -- "${file}"`,
- { cwd: projectPath }
+ const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
+ const { stdout } = await spawnAsync(
+ 'git', ['diff', 'HEAD', '--', repositoryRelativeFilePath],
+ { cwd: repositoryRootPath }
);
if (stdout) {
- diffContext += `\n--- ${file} ---\n${stdout}`;
+ diffContext += `\n--- ${repositoryRelativeFilePath} ---\n${stdout}`;
}
} catch (error) {
console.error(`Error getting diff for ${file}:`, error);
@@ -587,14 +850,15 @@ router.post('/generate-commit-message', async (req, res) => {
// Try to get content of untracked files
for (const file of files) {
try {
- const filePath = path.join(projectPath, file);
+ const { repositoryRelativeFilePath } = await resolveRepositoryFilePath(projectPath, file);
+ const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const stats = await fs.stat(filePath);
if (!stats.isDirectory()) {
const content = await fs.readFile(filePath, 'utf-8');
- diffContext += `\n--- ${file} (new file) ---\n${content.substring(0, 1000)}\n`;
+ diffContext += `\n--- ${repositoryRelativeFilePath} (new file) ---\n${content.substring(0, 1000)}\n`;
} else {
- diffContext += `\n--- ${file} (new directory) ---\n`;
+ diffContext += `\n--- ${repositoryRelativeFilePath} (new directory) ---\n`;
}
} catch (error) {
console.error(`Error reading file ${file}:`, error);
@@ -763,44 +1027,51 @@ router.get('/remote-status', async (req, res) => {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
- // Get current branch
- const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
- const branch = currentBranch.trim();
+ const branch = await getCurrentBranchName(projectPath);
+ const hasCommits = await repositoryHasCommits(projectPath);
+
+ const { stdout: remoteOutput } = await spawnAsync('git', ['remote'], { cwd: projectPath });
+ const remotes = remoteOutput.trim().split('\n').filter(r => r.trim());
+ const hasRemote = remotes.length > 0;
+ const fallbackRemoteName = hasRemote
+ ? (remotes.includes('origin') ? 'origin' : remotes[0])
+ : null;
+
+ // Repositories initialized with `git init` can have a branch but no commits.
+ // Return a non-error state so the UI can show the initial-commit workflow.
+ if (!hasCommits) {
+ return res.json({
+ hasRemote,
+ hasUpstream: false,
+ branch,
+ remoteName: fallbackRemoteName,
+ ahead: 0,
+ behind: 0,
+ isUpToDate: false,
+ message: 'Repository has no commits yet'
+ });
+ }
// Check if there's a remote tracking branch (smart detection)
let trackingBranch;
let remoteName;
try {
- const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
+ const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
trackingBranch = stdout.trim();
remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin")
} catch (error) {
- // No upstream branch configured - but check if we have remotes
- let hasRemote = false;
- let remoteName = null;
- try {
- const { stdout } = await execAsync('git remote', { cwd: projectPath });
- const remotes = stdout.trim().split('\n').filter(r => r.trim());
- if (remotes.length > 0) {
- hasRemote = true;
- remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
- }
- } catch (remoteError) {
- // No remotes configured
- }
-
- return res.json({
+ return res.json({
hasRemote,
hasUpstream: false,
branch,
- remoteName,
+ remoteName: fallbackRemoteName,
message: 'No remote tracking branch configured'
});
}
// Get ahead/behind counts
- const { stdout: countOutput } = await execAsync(
- `git rev-list --count --left-right ${trackingBranch}...HEAD`,
+ const { stdout: countOutput } = await spawnAsync(
+ 'git', ['rev-list', '--count', '--left-right', `${trackingBranch}...HEAD`],
{ cwd: projectPath }
);
@@ -835,20 +1106,20 @@ router.post('/fetch', async (req, res) => {
await validateGitRepository(projectPath);
// Get current branch and its upstream remote
- const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
- const branch = currentBranch.trim();
+ const branch = await getCurrentBranchName(projectPath);
let remoteName = 'origin'; // fallback
try {
- const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
+ const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
remoteName = stdout.trim().split('/')[0]; // Extract remote name
} catch (error) {
// No upstream, try to fetch from origin anyway
console.log('No upstream configured, using origin as fallback');
}
- const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: projectPath });
-
+ validateRemoteName(remoteName);
+ const { stdout } = await spawnAsync('git', ['fetch', remoteName], { cwd: projectPath });
+
res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName });
} catch (error) {
console.error('Git fetch error:', error);
@@ -876,13 +1147,12 @@ router.post('/pull', async (req, res) => {
await validateGitRepository(projectPath);
// Get current branch and its upstream remote
- const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
- const branch = currentBranch.trim();
+ const branch = await getCurrentBranchName(projectPath);
let remoteName = 'origin'; // fallback
let remoteBranch = branch; // fallback
try {
- const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
+ const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
const tracking = stdout.trim();
remoteName = tracking.split('/')[0]; // Extract remote name
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
@@ -891,17 +1161,19 @@ router.post('/pull', async (req, res) => {
console.log('No upstream configured, using origin/branch as fallback');
}
- const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: projectPath });
-
- res.json({
- success: true,
- output: stdout || 'Pull completed successfully',
+ validateRemoteName(remoteName);
+ validateBranchName(remoteBranch);
+ const { stdout } = await spawnAsync('git', ['pull', remoteName, remoteBranch], { cwd: projectPath });
+
+ res.json({
+ success: true,
+ output: stdout || 'Pull completed successfully',
remoteName,
remoteBranch
});
} catch (error) {
console.error('Git pull error:', error);
-
+
// Enhanced error handling for common pull scenarios
let errorMessage = 'Pull failed';
let details = error.message;
@@ -943,13 +1215,12 @@ router.post('/push', async (req, res) => {
await validateGitRepository(projectPath);
// Get current branch and its upstream remote
- const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
- const branch = currentBranch.trim();
+ const branch = await getCurrentBranchName(projectPath);
let remoteName = 'origin'; // fallback
let remoteBranch = branch; // fallback
try {
- const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath });
+ const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath });
const tracking = stdout.trim();
remoteName = tracking.split('/')[0]; // Extract remote name
remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name
@@ -958,11 +1229,13 @@ router.post('/push', async (req, res) => {
console.log('No upstream configured, using origin/branch as fallback');
}
- const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: projectPath });
-
- res.json({
- success: true,
- output: stdout || 'Push completed successfully',
+ validateRemoteName(remoteName);
+ validateBranchName(remoteBranch);
+ const { stdout } = await spawnAsync('git', ['push', remoteName, remoteBranch], { cwd: projectPath });
+
+ res.json({
+ success: true,
+ output: stdout || 'Push completed successfully',
remoteName,
remoteBranch
});
@@ -1012,35 +1285,38 @@ router.post('/publish', async (req, res) => {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
+ // Validate branch name
+ validateBranchName(branch);
+
// Get current branch to verify it matches the requested branch
- const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath });
- const currentBranchName = currentBranch.trim();
-
+ const currentBranchName = await getCurrentBranchName(projectPath);
+
if (currentBranchName !== branch) {
- return res.status(400).json({
- error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
+ return res.status(400).json({
+ error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}`
});
}
// Check if remote exists
let remoteName = 'origin';
try {
- const { stdout } = await execAsync('git remote', { cwd: projectPath });
+ const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath });
const remotes = stdout.trim().split('\n').filter(r => r.trim());
if (remotes.length === 0) {
- return res.status(400).json({
- error: 'No remote repository configured. Add a remote with: git remote add origin
'
+ return res.status(400).json({
+ error: 'No remote repository configured. Add a remote with: git remote add origin '
});
}
remoteName = remotes.includes('origin') ? 'origin' : remotes[0];
} catch (error) {
- return res.status(400).json({
- error: 'No remote repository configured. Add a remote with: git remote add origin '
+ return res.status(400).json({
+ error: 'No remote repository configured. Add a remote with: git remote add origin '
});
}
// Publish the branch (set upstream and push)
- const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath });
+ validateRemoteName(remoteName);
+ const { stdout } = await spawnAsync('git', ['push', '--set-upstream', remoteName, branch], { cwd: projectPath });
res.json({
success: true,
@@ -1087,10 +1363,18 @@ router.post('/discard', async (req, res) => {
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
+ const {
+ repositoryRootPath,
+ repositoryRelativeFilePath,
+ } = await resolveRepositoryFilePath(projectPath, file);
// Check file status to determine correct discard command
- const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
-
+ const { stdout: statusOutput } = await spawnAsync(
+ 'git',
+ ['status', '--porcelain', '--', repositoryRelativeFilePath],
+ { cwd: repositoryRootPath },
+ );
+
if (!statusOutput.trim()) {
return res.status(400).json({ error: 'No changes to discard for this file' });
}
@@ -1099,7 +1383,7 @@ router.post('/discard', async (req, res) => {
if (status === '??') {
// Untracked file or directory - delete it
- const filePath = path.join(projectPath, file);
+ const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
@@ -1109,13 +1393,13 @@ router.post('/discard', async (req, res) => {
}
} else if (status.includes('M') || status.includes('D')) {
// Modified or deleted file - restore from HEAD
- await execAsync(`git restore "${file}"`, { cwd: projectPath });
+ await spawnAsync('git', ['restore', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
} else if (status.includes('A')) {
// Added file - unstage it
- await execAsync(`git reset HEAD "${file}"`, { cwd: projectPath });
+ await spawnAsync('git', ['reset', 'HEAD', '--', repositoryRelativeFilePath], { cwd: repositoryRootPath });
}
- res.json({ success: true, message: `Changes discarded for ${file}` });
+ res.json({ success: true, message: `Changes discarded for ${repositoryRelativeFilePath}` });
} catch (error) {
console.error('Git discard error:', error);
res.status(500).json({ error: error.message });
@@ -1133,9 +1417,17 @@ router.post('/delete-untracked', async (req, res) => {
try {
const projectPath = await getActualProjectPath(project);
await validateGitRepository(projectPath);
+ const {
+ repositoryRootPath,
+ repositoryRelativeFilePath,
+ } = await resolveRepositoryFilePath(projectPath, file);
// Check if file is actually untracked
- const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath });
+ const { stdout: statusOutput } = await spawnAsync(
+ 'git',
+ ['status', '--porcelain', '--', repositoryRelativeFilePath],
+ { cwd: repositoryRootPath },
+ );
if (!statusOutput.trim()) {
return res.status(400).json({ error: 'File is not untracked or does not exist' });
@@ -1148,16 +1440,16 @@ router.post('/delete-untracked', async (req, res) => {
}
// Delete the untracked file or directory
- const filePath = path.join(projectPath, file);
+ const filePath = path.join(repositoryRootPath, repositoryRelativeFilePath);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) {
// Use rm with recursive option for directories
await fs.rm(filePath, { recursive: true, force: true });
- res.json({ success: true, message: `Untracked directory ${file} deleted successfully` });
+ res.json({ success: true, message: `Untracked directory ${repositoryRelativeFilePath} deleted successfully` });
} else {
await fs.unlink(filePath);
- res.json({ success: true, message: `Untracked file ${file} deleted successfully` });
+ res.json({ success: true, message: `Untracked file ${repositoryRelativeFilePath} deleted successfully` });
}
} catch (error) {
console.error('Git delete untracked error:', error);
diff --git a/server/routes/plugins.js b/server/routes/plugins.js
new file mode 100644
index 0000000..ef490c4
--- /dev/null
+++ b/server/routes/plugins.js
@@ -0,0 +1,303 @@
+import express from 'express';
+import path from 'path';
+import http from 'http';
+import mime from 'mime-types';
+import fs from 'fs';
+import {
+ scanPlugins,
+ getPluginsConfig,
+ getPluginsDir,
+ savePluginsConfig,
+ getPluginDir,
+ resolvePluginAssetPath,
+ installPluginFromGit,
+ updatePluginFromGit,
+ uninstallPlugin,
+} from '../utils/plugin-loader.js';
+import {
+ startPluginServer,
+ stopPluginServer,
+ getPluginPort,
+ isPluginRunning,
+} from '../utils/plugin-process-manager.js';
+
+const router = express.Router();
+
+// GET / — List all installed plugins (includes server running status)
+router.get('/', (req, res) => {
+ try {
+ const plugins = scanPlugins().map(p => ({
+ ...p,
+ serverRunning: p.server ? isPluginRunning(p.name) : false,
+ }));
+ res.json({ plugins });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to scan plugins', details: err.message });
+ }
+});
+
+// GET /:name/manifest — Get single plugin manifest
+router.get('/:name/manifest', (req, res) => {
+ try {
+ if (!/^[a-zA-Z0-9_-]+$/.test(req.params.name)) {
+ return res.status(400).json({ error: 'Invalid plugin name' });
+ }
+ const plugins = scanPlugins();
+ const plugin = plugins.find(p => p.name === req.params.name);
+ if (!plugin) {
+ return res.status(404).json({ error: 'Plugin not found' });
+ }
+ res.json(plugin);
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to read plugin manifest', details: err.message });
+ }
+});
+
+// GET /:name/assets/* — Serve plugin static files
+router.get('/:name/assets/*', (req, res) => {
+ const pluginName = req.params.name;
+ if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
+ return res.status(400).json({ error: 'Invalid plugin name' });
+ }
+ const assetPath = req.params[0];
+
+ if (!assetPath) {
+ return res.status(400).json({ error: 'No asset path specified' });
+ }
+
+ const resolvedPath = resolvePluginAssetPath(pluginName, assetPath);
+ if (!resolvedPath) {
+ return res.status(404).json({ error: 'Asset not found' });
+ }
+
+ try {
+ const stat = fs.statSync(resolvedPath);
+ if (!stat.isFile()) {
+ return res.status(404).json({ error: 'Asset not found' });
+ }
+ } catch {
+ return res.status(404).json({ error: 'Asset not found' });
+ }
+
+ const contentType = mime.lookup(resolvedPath) || 'application/octet-stream';
+ res.setHeader('Content-Type', contentType);
+ const stream = fs.createReadStream(resolvedPath);
+ stream.on('error', () => {
+ if (!res.headersSent) {
+ res.status(500).json({ error: 'Failed to read asset' });
+ } else {
+ res.end();
+ }
+ });
+ stream.pipe(res);
+});
+
+// PUT /:name/enable — Toggle plugin enabled/disabled (starts/stops server if applicable)
+router.put('/:name/enable', async (req, res) => {
+ try {
+ const { enabled } = req.body;
+ if (typeof enabled !== 'boolean') {
+ return res.status(400).json({ error: '"enabled" must be a boolean' });
+ }
+
+ const plugins = scanPlugins();
+ const plugin = plugins.find(p => p.name === req.params.name);
+ if (!plugin) {
+ return res.status(404).json({ error: 'Plugin not found' });
+ }
+
+ const config = getPluginsConfig();
+ config[req.params.name] = { ...config[req.params.name], enabled };
+ savePluginsConfig(config);
+
+ // Start or stop the plugin server as needed
+ if (plugin.server) {
+ if (enabled && !isPluginRunning(plugin.name)) {
+ const pluginDir = getPluginDir(plugin.name);
+ if (pluginDir) {
+ try {
+ await startPluginServer(plugin.name, pluginDir, plugin.server);
+ } catch (err) {
+ console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message);
+ }
+ }
+ } else if (!enabled && isPluginRunning(plugin.name)) {
+ await stopPluginServer(plugin.name);
+ }
+ }
+
+ res.json({ success: true, name: req.params.name, enabled });
+ } catch (err) {
+ res.status(500).json({ error: 'Failed to update plugin', details: err.message });
+ }
+});
+
+// POST /install — Install plugin from git URL
+router.post('/install', async (req, res) => {
+ try {
+ const { url } = req.body;
+ if (!url || typeof url !== 'string') {
+ return res.status(400).json({ error: '"url" is required and must be a string' });
+ }
+
+ // Basic URL validation
+ if (!url.startsWith('https://') && !url.startsWith('git@')) {
+ return res.status(400).json({ error: 'URL must start with https:// or git@' });
+ }
+
+ const manifest = await installPluginFromGit(url);
+
+ // Auto-start the server if the plugin has one (enabled by default)
+ if (manifest.server) {
+ const pluginDir = getPluginDir(manifest.name);
+ if (pluginDir) {
+ try {
+ await startPluginServer(manifest.name, pluginDir, manifest.server);
+ } catch (err) {
+ console.error(`[Plugins] Failed to start server for "${manifest.name}":`, err.message);
+ }
+ }
+ }
+
+ res.json({ success: true, plugin: manifest });
+ } catch (err) {
+ res.status(400).json({ error: 'Failed to install plugin', details: err.message });
+ }
+});
+
+// POST /:name/update — Pull latest from git (restarts server if running)
+router.post('/:name/update', async (req, res) => {
+ try {
+ const pluginName = req.params.name;
+
+ if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
+ return res.status(400).json({ error: 'Invalid plugin name' });
+ }
+
+ const wasRunning = isPluginRunning(pluginName);
+ if (wasRunning) {
+ await stopPluginServer(pluginName);
+ }
+
+ const manifest = await updatePluginFromGit(pluginName);
+
+ // Restart server if it was running before the update
+ if (wasRunning && manifest.server) {
+ const pluginDir = getPluginDir(pluginName);
+ if (pluginDir) {
+ try {
+ await startPluginServer(pluginName, pluginDir, manifest.server);
+ } catch (err) {
+ console.error(`[Plugins] Failed to restart server for "${pluginName}":`, err.message);
+ }
+ }
+ }
+
+ res.json({ success: true, plugin: manifest });
+ } catch (err) {
+ res.status(400).json({ error: 'Failed to update plugin', details: err.message });
+ }
+});
+
+// ALL /:name/rpc/* — Proxy requests to plugin's server subprocess
+router.all('/:name/rpc/*', async (req, res) => {
+ const pluginName = req.params.name;
+ const rpcPath = req.params[0] || '';
+
+ if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
+ return res.status(400).json({ error: 'Invalid plugin name' });
+ }
+
+ let port = getPluginPort(pluginName);
+ if (!port) {
+ // Lazily start the plugin server if it exists and is enabled
+ const plugins = scanPlugins();
+ const plugin = plugins.find(p => p.name === pluginName);
+ if (!plugin || !plugin.server) {
+ return res.status(503).json({ error: 'Plugin server is not running' });
+ }
+ if (!plugin.enabled) {
+ return res.status(503).json({ error: 'Plugin is disabled' });
+ }
+ const pluginDir = path.join(getPluginsDir(), plugin.dirName);
+ try {
+ port = await startPluginServer(pluginName, pluginDir, plugin.server);
+ } catch (err) {
+ return res.status(503).json({ error: 'Plugin server failed to start', details: err.message });
+ }
+ }
+
+ // Inject configured secrets as headers
+ const config = getPluginsConfig();
+ const pluginConfig = config[pluginName] || {};
+ const secrets = pluginConfig.secrets || {};
+
+ const headers = {
+ 'content-type': req.headers['content-type'] || 'application/json',
+ };
+
+ // Add per-plugin secrets as X-Plugin-Secret-* headers
+ for (const [key, value] of Object.entries(secrets)) {
+ headers[`x-plugin-secret-${key.toLowerCase()}`] = String(value);
+ }
+
+ // Reconstruct query string
+ const qs = req.url.includes('?') ? '?' + req.url.split('?').slice(1).join('?') : '';
+
+ const options = {
+ hostname: '127.0.0.1',
+ port,
+ path: `/${rpcPath}${qs}`,
+ method: req.method,
+ headers,
+ };
+
+ const proxyReq = http.request(options, (proxyRes) => {
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
+ proxyRes.pipe(res);
+ });
+
+ proxyReq.on('error', (err) => {
+ if (!res.headersSent) {
+ res.status(502).json({ error: 'Plugin server error', details: err.message });
+ } else {
+ res.end();
+ }
+ });
+
+ // Forward body (already parsed by express JSON middleware, so re-stringify).
+ // Check content-length to detect whether a body was actually sent, since
+ // req.body can be falsy for valid payloads like 0, false, null, or {}.
+ const hasBody = req.headers['content-length'] && parseInt(req.headers['content-length'], 10) > 0;
+ if (hasBody && req.body !== undefined) {
+ const bodyStr = JSON.stringify(req.body);
+ proxyReq.setHeader('content-length', Buffer.byteLength(bodyStr));
+ proxyReq.write(bodyStr);
+ }
+
+ proxyReq.end();
+});
+
+// DELETE /:name — Uninstall plugin (stops server first)
+router.delete('/:name', async (req, res) => {
+ try {
+ const pluginName = req.params.name;
+
+ // Validate name format to prevent path traversal
+ if (!/^[a-zA-Z0-9_-]+$/.test(pluginName)) {
+ return res.status(400).json({ error: 'Invalid plugin name' });
+ }
+
+ // Stop server and wait for the process to fully exit before deleting files
+ if (isPluginRunning(pluginName)) {
+ await stopPluginServer(pluginName);
+ }
+
+ await uninstallPlugin(pluginName);
+ res.json({ success: true, name: pluginName });
+ } catch (err) {
+ res.status(400).json({ error: 'Failed to uninstall plugin', details: err.message });
+ }
+});
+
+export default router;
diff --git a/server/routes/projects.js b/server/routes/projects.js
index 2f41610..cf3a62e 100644
--- a/server/routes/projects.js
+++ b/server/routes/projects.js
@@ -311,13 +311,11 @@ router.post('/create-workspace', async (req, res) => {
* Helper function to get GitHub token from database
*/
async function getGithubTokenById(tokenId, userId) {
- const { getDatabase } = await import('../database/db.js');
- const db = await getDatabase();
+ const { db } = await import('../database/db.js');
- const credential = await db.get(
- 'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1',
- [tokenId, userId, 'github_token']
- );
+ const credential = db.prepare(
+ 'SELECT * FROM user_credentials WHERE id = ? AND user_id = ? AND credential_type = ? AND is_active = 1'
+ ).get(tokenId, userId, 'github_token');
// Return in the expected format (github_token field for compatibility)
if (credential) {
diff --git a/server/routes/user.js b/server/routes/user.js
index ed39b0b..877cd45 100644
--- a/server/routes/user.js
+++ b/server/routes/user.js
@@ -2,12 +2,29 @@ import express from 'express';
import { userDb } from '../database/db.js';
import { authenticateToken } from '../middleware/auth.js';
import { getSystemGitConfig } from '../utils/gitConfig.js';
-import { exec } from 'child_process';
-import { promisify } from 'util';
+import { spawn } from 'child_process';
-const execAsync = promisify(exec);
const router = express.Router();
+function spawnAsync(command, args, options = {}) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, args, { ...options, shell: false });
+ let stdout = '';
+ let stderr = '';
+ child.stdout.on('data', (data) => { stdout += data.toString(); });
+ child.stderr.on('data', (data) => { stderr += data.toString(); });
+ child.on('error', (error) => { reject(error); });
+ child.on('close', (code) => {
+ if (code === 0) { resolve({ stdout, stderr }); return; }
+ const error = new Error(`Command failed: ${command} ${args.join(' ')}`);
+ error.code = code;
+ error.stdout = stdout;
+ error.stderr = stderr;
+ reject(error);
+ });
+ });
+}
+
router.get('/git-config', authenticateToken, async (req, res) => {
try {
const userId = req.user.id;
@@ -55,8 +72,8 @@ router.post('/git-config', authenticateToken, async (req, res) => {
userDb.updateGitConfig(userId, gitName, gitEmail);
try {
- await execAsync(`git config --global user.name "${gitName.replace(/"/g, '\\"')}"`);
- await execAsync(`git config --global user.email "${gitEmail.replace(/"/g, '\\"')}"`);
+ await spawnAsync('git', ['config', '--global', 'user.name', gitName]);
+ await spawnAsync('git', ['config', '--global', 'user.email', gitEmail]);
console.log(`Applied git config globally: ${gitName} <${gitEmail}>`);
} catch (gitError) {
console.error('Error applying git config:', gitError);
diff --git a/server/utils/commandParser.js b/server/utils/commandParser.js
index 11af5c7..56e3f70 100644
--- a/server/utils/commandParser.js
+++ b/server/utils/commandParser.js
@@ -1,9 +1,9 @@
-import matter from 'gray-matter';
import { promises as fs } from 'fs';
import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { parse as parseShellCommand } from 'shell-quote';
+import { parseFrontmatter } from './frontmatter.js';
const execFileAsync = promisify(execFile);
@@ -32,7 +32,7 @@ const BASH_COMMAND_ALLOWLIST = [
*/
export function parseCommand(content) {
try {
- const parsed = matter(content);
+ const parsed = parseFrontmatter(content);
return {
data: parsed.data || {},
content: parsed.content || '',
diff --git a/server/utils/frontmatter.js b/server/utils/frontmatter.js
new file mode 100644
index 0000000..0a4b1eb
--- /dev/null
+++ b/server/utils/frontmatter.js
@@ -0,0 +1,18 @@
+import matter from 'gray-matter';
+
+const disabledFrontmatterEngine = () => ({});
+
+const frontmatterOptions = {
+ language: 'yaml',
+ // Disable JS/JSON frontmatter parsing to avoid executable project content.
+ // Mirrors Gatsby's mitigation for gray-matter.
+ engines: {
+ js: disabledFrontmatterEngine,
+ javascript: disabledFrontmatterEngine,
+ json: disabledFrontmatterEngine
+ }
+};
+
+export function parseFrontmatter(content) {
+ return matter(content, frontmatterOptions);
+}
diff --git a/server/utils/gitConfig.js b/server/utils/gitConfig.js
index 2cae485..586933a 100644
--- a/server/utils/gitConfig.js
+++ b/server/utils/gitConfig.js
@@ -1,7 +1,17 @@
-import { exec } from 'child_process';
-import { promisify } from 'util';
+import { spawn } from 'child_process';
-const execAsync = promisify(exec);
+function spawnAsync(command, args) {
+ return new Promise((resolve, reject) => {
+ const child = spawn(command, args, { shell: false });
+ let stdout = '';
+ child.stdout.on('data', (data) => { stdout += data.toString(); });
+ child.on('error', (error) => { reject(error); });
+ child.on('close', (code) => {
+ if (code === 0) { resolve({ stdout }); return; }
+ reject(new Error(`Command failed with code ${code}`));
+ });
+ });
+}
/**
* Read git configuration from system's global git config
@@ -10,8 +20,8 @@ const execAsync = promisify(exec);
export async function getSystemGitConfig() {
try {
const [nameResult, emailResult] = await Promise.all([
- execAsync('git config --global user.name').catch(() => ({ stdout: '' })),
- execAsync('git config --global user.email').catch(() => ({ stdout: '' }))
+ spawnAsync('git', ['config', '--global', 'user.name']).catch(() => ({ stdout: '' })),
+ spawnAsync('git', ['config', '--global', 'user.email']).catch(() => ({ stdout: '' }))
]);
return {
diff --git a/server/utils/plugin-loader.js b/server/utils/plugin-loader.js
new file mode 100644
index 0000000..e48b768
--- /dev/null
+++ b/server/utils/plugin-loader.js
@@ -0,0 +1,408 @@
+import fs from 'fs';
+import path from 'path';
+import os from 'os';
+import { spawn } from 'child_process';
+
+const PLUGINS_DIR = path.join(os.homedir(), '.claude-code-ui', 'plugins');
+const PLUGINS_CONFIG_PATH = path.join(os.homedir(), '.claude-code-ui', 'plugins.json');
+
+const REQUIRED_MANIFEST_FIELDS = ['name', 'displayName', 'entry'];
+
+/** Strip embedded credentials from a repo URL before exposing it to the client. */
+function sanitizeRepoUrl(raw) {
+ try {
+ const u = new URL(raw);
+ u.username = '';
+ u.password = '';
+ return u.toString().replace(/\/$/, '');
+ } catch {
+ // Not a parseable URL (e.g. SSH shorthand) — strip user:pass@ segment
+ return raw.replace(/\/\/[^@/]+@/, '//');
+ }
+}
+const ALLOWED_TYPES = ['react', 'module'];
+const ALLOWED_SLOTS = ['tab'];
+
+export function getPluginsDir() {
+ if (!fs.existsSync(PLUGINS_DIR)) {
+ fs.mkdirSync(PLUGINS_DIR, { recursive: true });
+ }
+ return PLUGINS_DIR;
+}
+
+export function getPluginsConfig() {
+ try {
+ if (fs.existsSync(PLUGINS_CONFIG_PATH)) {
+ return JSON.parse(fs.readFileSync(PLUGINS_CONFIG_PATH, 'utf-8'));
+ }
+ } catch {
+ // Corrupted config, start fresh
+ }
+ return {};
+}
+
+export function savePluginsConfig(config) {
+ const dir = path.dirname(PLUGINS_CONFIG_PATH);
+ if (!fs.existsSync(dir)) {
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
+ }
+ fs.writeFileSync(PLUGINS_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
+}
+
+export function validateManifest(manifest) {
+ if (!manifest || typeof manifest !== 'object') {
+ return { valid: false, error: 'Manifest must be a JSON object' };
+ }
+
+ for (const field of REQUIRED_MANIFEST_FIELDS) {
+ if (!manifest[field] || typeof manifest[field] !== 'string') {
+ return { valid: false, error: `Missing or invalid required field: ${field}` };
+ }
+ }
+
+ // Sanitize name — only allow alphanumeric, hyphens, underscores
+ if (!/^[a-zA-Z0-9_-]+$/.test(manifest.name)) {
+ return { valid: false, error: 'Plugin name must only contain letters, numbers, hyphens, and underscores' };
+ }
+
+ if (manifest.type && !ALLOWED_TYPES.includes(manifest.type)) {
+ return { valid: false, error: `Invalid plugin type: ${manifest.type}. Must be one of: ${ALLOWED_TYPES.join(', ')}` };
+ }
+
+ if (manifest.slot && !ALLOWED_SLOTS.includes(manifest.slot)) {
+ return { valid: false, error: `Invalid plugin slot: ${manifest.slot}. Must be one of: ${ALLOWED_SLOTS.join(', ')}` };
+ }
+
+ // Validate entry is a relative path without traversal
+ if (manifest.entry.includes('..') || path.isAbsolute(manifest.entry)) {
+ return { valid: false, error: 'Entry must be a relative path without ".."' };
+ }
+
+ if (manifest.server !== undefined && manifest.server !== null) {
+ if (typeof manifest.server !== 'string' || manifest.server.includes('..') || path.isAbsolute(manifest.server)) {
+ return { valid: false, error: 'Server entry must be a relative path string without ".."' };
+ }
+ }
+
+ if (manifest.permissions !== undefined) {
+ if (!Array.isArray(manifest.permissions) || !manifest.permissions.every(p => typeof p === 'string')) {
+ return { valid: false, error: 'Permissions must be an array of strings' };
+ }
+ }
+
+ return { valid: true };
+}
+
+export function scanPlugins() {
+ const pluginsDir = getPluginsDir();
+ const config = getPluginsConfig();
+ const plugins = [];
+
+ let entries;
+ try {
+ entries = fs.readdirSync(pluginsDir, { withFileTypes: true });
+ } catch {
+ return plugins;
+ }
+
+ const seenNames = new Set();
+
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ // Skip transient temp directories from in-progress installs
+ if (entry.name.startsWith('.tmp-')) continue;
+
+ const manifestPath = path.join(pluginsDir, entry.name, 'manifest.json');
+ if (!fs.existsSync(manifestPath)) continue;
+
+ try {
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+ const validation = validateManifest(manifest);
+ if (!validation.valid) {
+ console.warn(`[Plugins] Skipping ${entry.name}: ${validation.error}`);
+ continue;
+ }
+
+ // Skip duplicate manifest names
+ if (seenNames.has(manifest.name)) {
+ console.warn(`[Plugins] Skipping ${entry.name}: duplicate plugin name "${manifest.name}"`);
+ continue;
+ }
+ seenNames.add(manifest.name);
+
+ // Try to read git remote URL
+ let repoUrl = null;
+ try {
+ const gitConfigPath = path.join(pluginsDir, entry.name, '.git', 'config');
+ if (fs.existsSync(gitConfigPath)) {
+ const gitConfig = fs.readFileSync(gitConfigPath, 'utf-8');
+ const match = gitConfig.match(/url\s*=\s*(.+)/);
+ if (match) {
+ repoUrl = match[1].trim().replace(/\.git$/, '');
+ // Convert SSH URLs to HTTPS
+ if (repoUrl.startsWith('git@')) {
+ repoUrl = repoUrl.replace(/^git@([^:]+):/, 'https://$1/');
+ }
+ // Strip embedded credentials (e.g. https://user:pass@host/...)
+ repoUrl = sanitizeRepoUrl(repoUrl);
+ }
+ }
+ } catch { /* ignore */ }
+
+ plugins.push({
+ name: manifest.name,
+ displayName: manifest.displayName,
+ version: manifest.version || '0.0.0',
+ description: manifest.description || '',
+ author: manifest.author || '',
+ icon: manifest.icon || 'Puzzle',
+ type: manifest.type || 'module',
+ slot: manifest.slot || 'tab',
+ entry: manifest.entry,
+ server: manifest.server || null,
+ permissions: manifest.permissions || [],
+ enabled: config[manifest.name]?.enabled !== false, // enabled by default
+ dirName: entry.name,
+ repoUrl,
+ });
+ } catch (err) {
+ console.warn(`[Plugins] Failed to read manifest for ${entry.name}:`, err.message);
+ }
+ }
+
+ return plugins;
+}
+
+export function getPluginDir(name) {
+ const plugins = scanPlugins();
+ const plugin = plugins.find(p => p.name === name);
+ if (!plugin) return null;
+ return path.join(getPluginsDir(), plugin.dirName);
+}
+
+export function resolvePluginAssetPath(name, assetPath) {
+ const pluginDir = getPluginDir(name);
+ if (!pluginDir) return null;
+
+ const resolved = path.resolve(pluginDir, assetPath);
+
+ // Prevent path traversal — canonicalize via realpath to defeat symlink bypasses
+ if (!fs.existsSync(resolved)) return null;
+
+ const realResolved = fs.realpathSync(resolved);
+ const realPluginDir = fs.realpathSync(pluginDir);
+ if (!realResolved.startsWith(realPluginDir + path.sep) && realResolved !== realPluginDir) {
+ return null;
+ }
+
+ return realResolved;
+}
+
+export function installPluginFromGit(url) {
+ return new Promise((resolve, reject) => {
+ if (typeof url !== 'string' || !url.trim()) {
+ return reject(new Error('Invalid URL: must be a non-empty string'));
+ }
+ if (url.startsWith('-')) {
+ return reject(new Error('Invalid URL: must not start with "-"'));
+ }
+
+ // Extract repo name from URL for directory name
+ const urlClean = url.replace(/\.git$/, '').replace(/\/$/, '');
+ const repoName = urlClean.split('/').pop();
+
+ if (!repoName || !/^[a-zA-Z0-9_.-]+$/.test(repoName)) {
+ return reject(new Error('Could not determine a valid directory name from the URL'));
+ }
+
+ const pluginsDir = getPluginsDir();
+ const targetDir = path.resolve(pluginsDir, repoName);
+
+ // Ensure the resolved target directory stays within the plugins directory
+ if (!targetDir.startsWith(pluginsDir + path.sep)) {
+ return reject(new Error('Invalid plugin directory path'));
+ }
+
+ if (fs.existsSync(targetDir)) {
+ return reject(new Error(`Plugin directory "${repoName}" already exists`));
+ }
+
+ // Clone into a temp directory so scanPlugins() never sees a partially-installed plugin
+ const tempDir = fs.mkdtempSync(path.join(pluginsDir, `.tmp-${repoName}-`));
+
+ const cleanupTemp = () => {
+ try { fs.rmSync(tempDir, { recursive: true, force: true }); } catch {}
+ };
+
+ const finalize = (manifest) => {
+ try {
+ fs.renameSync(tempDir, targetDir);
+ } catch (err) {
+ cleanupTemp();
+ return reject(new Error(`Failed to move plugin into place: ${err.message}`));
+ }
+ resolve(manifest);
+ };
+
+ const gitProcess = spawn('git', ['clone', '--depth', '1', '--', url, tempDir], {
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ let stderr = '';
+ gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
+
+ gitProcess.on('close', (code) => {
+ if (code !== 0) {
+ cleanupTemp();
+ return reject(new Error(`git clone failed (exit code ${code}): ${stderr.trim()}`));
+ }
+
+ // Validate manifest exists
+ const manifestPath = path.join(tempDir, 'manifest.json');
+ if (!fs.existsSync(manifestPath)) {
+ cleanupTemp();
+ return reject(new Error('Cloned repository does not contain a manifest.json'));
+ }
+
+ let manifest;
+ try {
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+ } catch {
+ cleanupTemp();
+ return reject(new Error('manifest.json is not valid JSON'));
+ }
+
+ const validation = validateManifest(manifest);
+ if (!validation.valid) {
+ cleanupTemp();
+ return reject(new Error(`Invalid manifest: ${validation.error}`));
+ }
+
+ // Reject if another installed plugin already uses this name
+ const existing = scanPlugins().find(p => p.name === manifest.name);
+ if (existing) {
+ cleanupTemp();
+ return reject(new Error(`A plugin named "${manifest.name}" is already installed (in "${existing.dirName}")`));
+ }
+
+ // Run npm install if package.json exists.
+ // --ignore-scripts prevents postinstall hooks from executing arbitrary code.
+ const packageJsonPath = path.join(tempDir, 'package.json');
+ if (fs.existsSync(packageJsonPath)) {
+ const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
+ cwd: tempDir,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ npmProcess.on('close', (npmCode) => {
+ if (npmCode !== 0) {
+ cleanupTemp();
+ return reject(new Error(`npm install for ${repoName} failed (exit code ${npmCode})`));
+ }
+ finalize(manifest);
+ });
+
+ npmProcess.on('error', (err) => {
+ cleanupTemp();
+ reject(err);
+ });
+ } else {
+ finalize(manifest);
+ }
+ });
+
+ gitProcess.on('error', (err) => {
+ cleanupTemp();
+ reject(new Error(`Failed to spawn git: ${err.message}`));
+ });
+ });
+}
+
+export function updatePluginFromGit(name) {
+ return new Promise((resolve, reject) => {
+ const pluginDir = getPluginDir(name);
+ if (!pluginDir) {
+ return reject(new Error(`Plugin "${name}" not found`));
+ }
+
+ // Only fast-forward to avoid silent divergence
+ const gitProcess = spawn('git', ['pull', '--ff-only', '--'], {
+ cwd: pluginDir,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ let stderr = '';
+ gitProcess.stderr.on('data', (data) => { stderr += data.toString(); });
+
+ gitProcess.on('close', (code) => {
+ if (code !== 0) {
+ return reject(new Error(`git pull failed (exit code ${code}): ${stderr.trim()}`));
+ }
+
+ // Re-validate manifest after update
+ const manifestPath = path.join(pluginDir, 'manifest.json');
+ let manifest;
+ try {
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
+ } catch {
+ return reject(new Error('manifest.json is not valid JSON after update'));
+ }
+
+ const validation = validateManifest(manifest);
+ if (!validation.valid) {
+ return reject(new Error(`Invalid manifest after update: ${validation.error}`));
+ }
+
+ // Re-run npm install if package.json exists
+ const packageJsonPath = path.join(pluginDir, 'package.json');
+ if (fs.existsSync(packageJsonPath)) {
+ const npmProcess = spawn('npm', ['install', '--production', '--ignore-scripts'], {
+ cwd: pluginDir,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ npmProcess.on('close', (npmCode) => {
+ if (npmCode !== 0) {
+ return reject(new Error(`npm install for ${name} failed (exit code ${npmCode})`));
+ }
+ resolve(manifest);
+ });
+ npmProcess.on('error', (err) => reject(err));
+ } else {
+ resolve(manifest);
+ }
+ });
+
+ gitProcess.on('error', (err) => {
+ reject(new Error(`Failed to spawn git: ${err.message}`));
+ });
+ });
+}
+
+export async function uninstallPlugin(name) {
+ const pluginDir = getPluginDir(name);
+ if (!pluginDir) {
+ throw new Error(`Plugin "${name}" not found`);
+ }
+
+ // On Windows, file handles may be released slightly after process exit.
+ // Retry a few times with a short delay before giving up.
+ const MAX_RETRIES = 5;
+ const RETRY_DELAY_MS = 500;
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
+ try {
+ fs.rmSync(pluginDir, { recursive: true, force: true });
+ break;
+ } catch (err) {
+ if (err.code === 'EBUSY' && attempt < MAX_RETRIES) {
+ await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
+ } else {
+ throw err;
+ }
+ }
+ }
+
+ // Remove from config
+ const config = getPluginsConfig();
+ delete config[name];
+ savePluginsConfig(config);
+}
diff --git a/server/utils/plugin-process-manager.js b/server/utils/plugin-process-manager.js
new file mode 100644
index 0000000..d5fa493
--- /dev/null
+++ b/server/utils/plugin-process-manager.js
@@ -0,0 +1,184 @@
+import { spawn } from 'child_process';
+import path from 'path';
+import { scanPlugins, getPluginsConfig, getPluginDir } from './plugin-loader.js';
+
+// Map
+const runningPlugins = new Map();
+// Map> — in-flight start operations
+const startingPlugins = new Map();
+
+/**
+ * Start a plugin's server subprocess.
+ * The plugin's server entry must print a JSON line with { ready: true, port: }
+ * to stdout within 10 seconds.
+ */
+export function startPluginServer(name, pluginDir, serverEntry) {
+ if (runningPlugins.has(name)) {
+ return Promise.resolve(runningPlugins.get(name).port);
+ }
+
+ // Coalesce concurrent starts for the same plugin
+ if (startingPlugins.has(name)) {
+ return startingPlugins.get(name);
+ }
+
+ const startPromise = new Promise((resolve, reject) => {
+
+ const serverPath = path.join(pluginDir, serverEntry);
+
+ // Restricted env — only essentials, no host secrets
+ const pluginProcess = spawn('node', [serverPath], {
+ cwd: pluginDir,
+ env: {
+ PATH: process.env.PATH,
+ HOME: process.env.HOME,
+ NODE_ENV: process.env.NODE_ENV || 'production',
+ PLUGIN_NAME: name,
+ },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ let resolved = false;
+ let stdout = '';
+
+ const timeout = setTimeout(() => {
+ if (!resolved) {
+ resolved = true;
+ pluginProcess.kill();
+ reject(new Error('Plugin server did not report ready within 10 seconds'));
+ }
+ }, 10000);
+
+ pluginProcess.stdout.on('data', (data) => {
+ if (resolved) return;
+ stdout += data.toString();
+
+ // Look for the JSON ready line
+ const lines = stdout.split('\n');
+ for (const line of lines) {
+ try {
+ const msg = JSON.parse(line.trim());
+ if (msg.ready && typeof msg.port === 'number') {
+ clearTimeout(timeout);
+ resolved = true;
+ runningPlugins.set(name, { process: pluginProcess, port: msg.port });
+
+ pluginProcess.on('exit', () => {
+ runningPlugins.delete(name);
+ });
+
+ console.log(`[Plugins] Server started for "${name}" on port ${msg.port}`);
+ resolve(msg.port);
+ }
+ } catch {
+ // Not JSON yet, keep buffering
+ }
+ }
+ });
+
+ pluginProcess.stderr.on('data', (data) => {
+ console.warn(`[Plugin:${name}] ${data.toString().trim()}`);
+ });
+
+ pluginProcess.on('error', (err) => {
+ clearTimeout(timeout);
+ if (!resolved) {
+ resolved = true;
+ reject(new Error(`Failed to start plugin server: ${err.message}`));
+ }
+ });
+
+ pluginProcess.on('exit', (code) => {
+ clearTimeout(timeout);
+ runningPlugins.delete(name);
+ if (!resolved) {
+ resolved = true;
+ reject(new Error(`Plugin server exited with code ${code} before reporting ready`));
+ }
+ });
+ }).finally(() => {
+ startingPlugins.delete(name);
+ });
+
+ startingPlugins.set(name, startPromise);
+ return startPromise;
+}
+
+/**
+ * Stop a plugin's server subprocess.
+ * Returns a Promise that resolves when the process has fully exited.
+ */
+export function stopPluginServer(name) {
+ const entry = runningPlugins.get(name);
+ if (!entry) return Promise.resolve();
+
+ return new Promise((resolve) => {
+ const cleanup = () => {
+ clearTimeout(forceKillTimer);
+ runningPlugins.delete(name);
+ resolve();
+ };
+
+ entry.process.once('exit', cleanup);
+
+ entry.process.kill('SIGTERM');
+
+ // Force kill after 5 seconds if still running
+ const forceKillTimer = setTimeout(() => {
+ if (runningPlugins.has(name)) {
+ entry.process.kill('SIGKILL');
+ cleanup();
+ }
+ }, 5000);
+
+ console.log(`[Plugins] Server stopped for "${name}"`);
+ });
+}
+
+/**
+ * Get the port a running plugin server is listening on.
+ */
+export function getPluginPort(name) {
+ return runningPlugins.get(name)?.port ?? null;
+}
+
+/**
+ * Check if a plugin's server is running.
+ */
+export function isPluginRunning(name) {
+ return runningPlugins.has(name);
+}
+
+/**
+ * Stop all running plugin servers (called on host shutdown).
+ */
+export function stopAllPlugins() {
+ const stops = [];
+ for (const [name] of runningPlugins) {
+ stops.push(stopPluginServer(name));
+ }
+ return Promise.all(stops);
+}
+
+/**
+ * Start servers for all enabled plugins that have a server entry.
+ * Called once on host server boot.
+ */
+export async function startEnabledPluginServers() {
+ const plugins = scanPlugins();
+ const config = getPluginsConfig();
+
+ for (const plugin of plugins) {
+ if (!plugin.server) continue;
+ if (config[plugin.name]?.enabled === false) continue;
+
+ const pluginDir = getPluginDir(plugin.name);
+ if (!pluginDir) continue;
+
+ try {
+ await startPluginServer(plugin.name, pluginDir, plugin.server);
+ } catch (err) {
+ console.error(`[Plugins] Failed to start server for "${plugin.name}":`, err.message);
+ }
+ }
+}
diff --git a/shared/modelConstants.js b/shared/modelConstants.js
index c476955..514a177 100644
--- a/shared/modelConstants.js
+++ b/shared/modelConstants.js
@@ -13,14 +13,14 @@
export const CLAUDE_MODELS = {
// Models in SDK format (what the actual SDK accepts)
OPTIONS: [
- { value: 'sonnet', label: 'Sonnet' },
- { value: 'opus', label: 'Opus' },
- { value: 'haiku', label: 'Haiku' },
- { value: 'opusplan', label: 'Opus Plan' },
- { value: 'sonnet[1m]', label: 'Sonnet [1M]' }
+ { value: "sonnet", label: "Sonnet" },
+ { value: "opus", label: "Opus" },
+ { value: "haiku", label: "Haiku" },
+ { value: "opusplan", label: "Opus Plan" },
+ { value: "sonnet[1m]", label: "Sonnet [1M]" },
],
- DEFAULT: 'sonnet'
+ DEFAULT: "sonnet",
};
/**
@@ -28,28 +28,28 @@ export const CLAUDE_MODELS = {
*/
export const CURSOR_MODELS = {
OPTIONS: [
- { value: 'opus-4.6-thinking', label: 'Claude 4.6 Opus (Thinking)' },
- { value: 'gpt-5.3-codex', label: 'GPT-5.3' },
- { value: 'gpt-5.2-high', label: 'GPT-5.2 High' },
- { value: 'gemini-3-pro', label: 'Gemini 3 Pro' },
- { value: 'opus-4.5-thinking', label: 'Claude 4.5 Opus (Thinking)' },
- { value: 'gpt-5.2', label: 'GPT-5.2' },
- { value: 'gpt-5.1', label: 'GPT-5.1' },
- { value: 'gpt-5.1-high', label: 'GPT-5.1 High' },
- { value: 'composer-1', label: 'Composer 1' },
- { value: 'auto', label: 'Auto' },
- { value: 'sonnet-4.5', label: 'Claude 4.5 Sonnet' },
- { value: 'sonnet-4.5-thinking', label: 'Claude 4.5 Sonnet (Thinking)' },
- { value: 'opus-4.5', label: 'Claude 4.5 Opus' },
- { value: 'gpt-5.1-codex', label: 'GPT-5.1 Codex' },
- { value: 'gpt-5.1-codex-high', label: 'GPT-5.1 Codex High' },
- { value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
- { value: 'gpt-5.1-codex-max-high', label: 'GPT-5.1 Codex Max High' },
- { value: 'opus-4.1', label: 'Claude 4.1 Opus' },
- { value: 'grok', label: 'Grok' }
+ { value: "opus-4.6-thinking", label: "Claude 4.6 Opus (Thinking)" },
+ { value: "gpt-5.3-codex", label: "GPT-5.3" },
+ { value: "gpt-5.2-high", label: "GPT-5.2 High" },
+ { value: "gemini-3-pro", label: "Gemini 3 Pro" },
+ { value: "opus-4.5-thinking", label: "Claude 4.5 Opus (Thinking)" },
+ { value: "gpt-5.2", label: "GPT-5.2" },
+ { value: "gpt-5.1", label: "GPT-5.1" },
+ { value: "gpt-5.1-high", label: "GPT-5.1 High" },
+ { value: "composer-1", label: "Composer 1" },
+ { value: "auto", label: "Auto" },
+ { value: "sonnet-4.5", label: "Claude 4.5 Sonnet" },
+ { value: "sonnet-4.5-thinking", label: "Claude 4.5 Sonnet (Thinking)" },
+ { value: "opus-4.5", label: "Claude 4.5 Opus" },
+ { value: "gpt-5.1-codex", label: "GPT-5.1 Codex" },
+ { value: "gpt-5.1-codex-high", label: "GPT-5.1 Codex High" },
+ { value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
+ { value: "gpt-5.1-codex-max-high", label: "GPT-5.1 Codex Max High" },
+ { value: "opus-4.1", label: "Claude 4.1 Opus" },
+ { value: "grok", label: "Grok" },
],
- DEFAULT: 'gpt-5-3-codex'
+ DEFAULT: "gpt-5-3-codex",
};
/**
@@ -57,17 +57,16 @@ export const CURSOR_MODELS = {
*/
export const CODEX_MODELS = {
OPTIONS: [
- { value: 'gpt-5.4', label: 'GPT-5.4' },
- { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
- { value: 'gpt-5.3-codex', label: 'GPT-5.3 Codex' },
- { value: 'gpt-5.2-codex', label: 'GPT-5.2 Codex' },
- { value: 'gpt-5.2', label: 'GPT-5.2' },
- { value: 'gpt-5.1-codex-max', label: 'GPT-5.1 Codex Max' },
- { value: 'o3', label: 'O3' },
- { value: 'o4-mini', label: 'O4-mini' }
+ { value: "gpt-5.4", label: "GPT-5.4" },
+ { value: "gpt-5.3-codex", label: "GPT-5.3 Codex" },
+ { value: "gpt-5.2-codex", label: "GPT-5.2 Codex" },
+ { value: "gpt-5.2", label: "GPT-5.2" },
+ { value: "gpt-5.1-codex-max", label: "GPT-5.1 Codex Max" },
+ { value: "o3", label: "O3" },
+ { value: "o4-mini", label: "O4-mini" },
],
- DEFAULT: 'gpt-5.4'
+ DEFAULT: "gpt-5.4",
};
/**
@@ -75,16 +74,19 @@ export const CODEX_MODELS = {
*/
export const GEMINI_MODELS = {
OPTIONS: [
- { value: 'gemini-3.1-pro-preview', label: 'Gemini 3.1 Pro Preview' },
- { value: 'gemini-3-pro-preview', label: 'Gemini 3 Pro Preview' },
- { value: 'gemini-3-flash-preview', label: 'Gemini 3 Flash Preview' },
- { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
- { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
- { value: 'gemini-2.0-flash-lite', label: 'Gemini 2.0 Flash Lite' },
- { value: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
- { value: 'gemini-2.0-pro-exp', label: 'Gemini 2.0 Pro Experimental' },
- { value: 'gemini-2.0-flash-thinking-exp', label: 'Gemini 2.0 Flash Thinking' }
+ { value: "gemini-3.1-pro-preview", label: "Gemini 3.1 Pro Preview" },
+ { value: "gemini-3-pro-preview", label: "Gemini 3 Pro Preview" },
+ { value: "gemini-3-flash-preview", label: "Gemini 3 Flash Preview" },
+ { value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
+ { value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
+ { value: "gemini-2.0-flash-lite", label: "Gemini 2.0 Flash Lite" },
+ { value: "gemini-2.0-flash", label: "Gemini 2.0 Flash" },
+ { value: "gemini-2.0-pro-exp", label: "Gemini 2.0 Pro Experimental" },
+ {
+ value: "gemini-2.0-flash-thinking-exp",
+ label: "Gemini 2.0 Flash Thinking",
+ },
],
- DEFAULT: 'gemini-2.5-flash'
+ DEFAULT: "gemini-2.5-flash",
};
diff --git a/src/App.tsx b/src/App.tsx
index 564ee1a..bcbda82 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,6 +5,7 @@ import { AuthProvider, ProtectedRoute } from './components/auth';
import { TaskMasterProvider } from './contexts/TaskMasterContext';
import { TasksSettingsProvider } from './contexts/TasksSettingsContext';
import { WebSocketProvider } from './contexts/WebSocketContext';
+import { PluginsProvider } from './contexts/PluginsContext';
import AppContent from './components/app/AppContent';
import i18n from './i18n/config.js';
@@ -14,8 +15,9 @@ export default function App() {
-
-
+
+
+
@@ -24,8 +26,9 @@ export default function App() {
-
-
+
+
+
diff --git a/src/components/app/AppContent.tsx b/src/components/app/AppContent.tsx
index f2806c6..5649c0c 100644
--- a/src/components/app/AppContent.tsx
+++ b/src/components/app/AppContent.tsx
@@ -40,7 +40,7 @@ export default function AppContent() {
setIsInputFocused,
setShowSettings,
openSettings,
- fetchProjects,
+ refreshProjectsSilently,
sidebarSharedProps,
} = useProjectsState({
sessionId,
@@ -51,14 +51,16 @@ export default function AppContent() {
});
useEffect(() => {
- window.refreshProjects = fetchProjects;
+ // Expose a non-blocking refresh for chat/session flows.
+ // Full loading refreshes are still available through direct fetchProjects calls.
+ window.refreshProjects = refreshProjectsSilently;
return () => {
- if (window.refreshProjects === fetchProjects) {
+ if (window.refreshProjects === refreshProjectsSilently) {
delete window.refreshProjects;
}
};
- }, [fetchProjects]);
+ }, [refreshProjectsSilently]);
useEffect(() => {
window.openSettings = openSettings;
diff --git a/src/components/app/MobileNav.tsx b/src/components/app/MobileNav.tsx
index 0ca82bc..8a672be 100644
--- a/src/components/app/MobileNav.tsx
+++ b/src/components/app/MobileNav.tsx
@@ -1,8 +1,36 @@
-import { MessageSquare, Folder, Terminal, GitBranch, ClipboardCheck } from 'lucide-react';
-import { Dispatch, SetStateAction } from 'react';
+import { useState, useRef, useEffect, type Dispatch, type SetStateAction } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ MessageSquare,
+ Folder,
+ Terminal,
+ GitBranch,
+ ClipboardCheck,
+ Ellipsis,
+ Puzzle,
+ Box,
+ Database,
+ Globe,
+ Wrench,
+ Zap,
+ BarChart3,
+ type LucideIcon,
+} from 'lucide-react';
import { useTasksSettings } from '../../contexts/TasksSettingsContext';
+import { usePlugins } from '../../contexts/PluginsContext';
import { AppTab } from '../../types/app';
+const PLUGIN_ICON_MAP: Record = {
+ Puzzle, Box, Database, Globe, Terminal, Wrench, Zap, BarChart3, Folder, MessageSquare, GitBranch,
+};
+
+type CoreTabId = Exclude;
+type CoreNavItem = {
+ id: CoreTabId;
+ icon: LucideIcon;
+ label: string;
+};
+
type MobileNavProps = {
activeTab: AppTab;
setActiveTab: Dispatch>;
@@ -10,41 +38,46 @@ type MobileNavProps = {
};
export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: MobileNavProps) {
+ const { t } = useTranslation(['common', 'settings']);
const { tasksEnabled, isTaskMasterInstalled } = useTasksSettings();
const shouldShowTasksTab = Boolean(tasksEnabled && isTaskMasterInstalled);
+ const { plugins } = usePlugins();
+ const [moreOpen, setMoreOpen] = useState(false);
+ const moreRef = useRef(null);
- const navItems = [
- {
- id: 'chat',
- icon: MessageSquare,
- label: 'Chat',
- onClick: () => setActiveTab('chat')
- },
- {
- id: 'shell',
- icon: Terminal,
- label: 'Shell',
- onClick: () => setActiveTab('shell')
- },
- {
- id: 'files',
- icon: Folder,
- label: 'Files',
- onClick: () => setActiveTab('files')
- },
- {
- id: 'git',
- icon: GitBranch,
- label: 'Git',
- onClick: () => setActiveTab('git')
- },
- ...(shouldShowTasksTab ? [{
- id: 'tasks',
- icon: ClipboardCheck,
- label: 'Tasks',
- onClick: () => setActiveTab('tasks')
- }] : [])
+ const enabledPlugins = plugins.filter((p) => p.enabled);
+ const hasPlugins = enabledPlugins.length > 0;
+ const isPluginActive = activeTab.startsWith('plugin:');
+
+ // Close the menu on outside tap
+ useEffect(() => {
+ if (!moreOpen) return;
+ const handleTap = (e: PointerEvent) => {
+ const target = e.target;
+ if (moreRef.current && target instanceof Node && !moreRef.current.contains(target)) {
+ setMoreOpen(false);
+ }
+ };
+ document.addEventListener('pointerdown', handleTap);
+ return () => document.removeEventListener('pointerdown', handleTap);
+ }, [moreOpen]);
+
+ // Close menu when a plugin tab is selected
+ const selectPlugin = (name: string) => {
+ const pluginTab = `plugin:${name}` as AppTab;
+ setActiveTab(pluginTab);
+ setMoreOpen(false);
+ };
+
+ const baseCoreItems: CoreNavItem[] = [
+ { id: 'chat', icon: MessageSquare, label: 'Chat' },
+ { id: 'shell', icon: Terminal, label: 'Shell' },
+ { id: 'files', icon: Folder, label: 'Files' },
+ { id: 'git', icon: GitBranch, label: 'Git' },
];
+ const coreItems: CoreNavItem[] = shouldShowTasksTab
+ ? [...baseCoreItems, { id: 'tasks', icon: ClipboardCheck, label: 'Tasks' }]
+ : baseCoreItems;
return (
- {navItems.map((item) => {
+ {coreItems.map((item) => {
const Icon = item.icon;
const isActive = activeTab === item.id;
return (
setActiveTab(item.id)}
onTouchStart={(e) => {
e.preventDefault();
- item.onClick();
+ setActiveTab(item.id);
}}
className={`relative flex flex-1 touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isActive
? 'text-primary'
@@ -85,6 +118,60 @@ export default function MobileNav({ activeTab, setActiveTab, isInputFocused }: M
);
})}
+
+ {/* "More" button — only shown when there are enabled plugins */}
+ {hasPlugins && (
+
+
setMoreOpen((v) => !v)}
+ onTouchStart={(e) => {
+ e.preventDefault();
+ setMoreOpen((v) => !v);
+ }}
+ className={`relative flex w-full touch-manipulation flex-col items-center justify-center gap-0.5 rounded-xl px-3 py-2 transition-all duration-200 active:scale-95 ${isPluginActive || moreOpen
+ ? 'text-primary'
+ : 'text-muted-foreground hover:text-foreground'
+ }`}
+ aria-label="More plugins"
+ aria-expanded={moreOpen}
+ >
+ {(isPluginActive && !moreOpen) && (
+
+ )}
+
+
+ {t('settings:pluginSettings.morePlugins')}
+
+
+
+ {/* Popover menu */}
+ {moreOpen && (
+
+ {enabledPlugins.map((p) => {
+ const Icon = PLUGIN_ICON_MAP[p.icon] || Puzzle;
+ const isActive = activeTab === `plugin:${p.name}`;
+
+ return (
+ selectPlugin(p.name)}
+ className={`flex w-full items-center gap-2.5 px-3.5 py-2.5 text-sm transition-colors ${isActive
+ ? 'bg-primary/8 text-primary'
+ : 'text-foreground hover:bg-muted/60'
+ }`}
+ >
+
+ {p.displayName}
+
+ );
+ })}
+
+ )}
+
+ )}
diff --git a/src/components/chat/hooks/useChatRealtimeHandlers.ts b/src/components/chat/hooks/useChatRealtimeHandlers.ts
index 73c305d..ff957af 100644
--- a/src/components/chat/hooks/useChatRealtimeHandlers.ts
+++ b/src/components/chat/hooks/useChatRealtimeHandlers.ts
@@ -48,6 +48,7 @@ interface UseChatRealtimeHandlersArgs {
onSessionNotProcessing?: (sessionId?: string | null) => void;
onReplaceTemporarySession?: (sessionId?: string | null) => void;
onNavigateToSession?: (sessionId: string) => void;
+ onWebSocketReconnect?: () => void;
}
const appendStreamingChunk = (
@@ -113,6 +114,7 @@ export function useChatRealtimeHandlers({
onSessionNotProcessing,
onReplaceTemporarySession,
onNavigateToSession,
+ onWebSocketReconnect,
}: UseChatRealtimeHandlersArgs) {
const lastProcessedMessageRef = useRef(null);
@@ -136,7 +138,7 @@ export function useChatRealtimeHandlers({
: null;
const messageType = String(latestMessage.type);
- const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created'];
+ const globalMessageTypes = ['projects_updated', 'taskmaster-project-updated', 'session-created', 'websocket-reconnected'];
const isGlobalMessage = globalMessageTypes.includes(messageType);
const lifecycleMessageTypes = new Set([
'claude-complete',
@@ -300,6 +302,11 @@ export function useChatRealtimeHandlers({
}
break;
+ case 'websocket-reconnected':
+ // WebSocket dropped and reconnected — re-fetch session history to catch up on missed messages
+ onWebSocketReconnect?.();
+ break;
+
case 'token-budget':
if (latestMessage.data) {
setTokenBudget(latestMessage.data);
@@ -692,14 +699,28 @@ export function useChatRealtimeHandlers({
const updated = [...previous];
const lastIndex = updated.length - 1;
const last = updated[lastIndex];
+ const normalizedTextResult = textResult.trim();
+
if (last && last.type === 'assistant' && !last.isToolUse && last.isStreaming) {
const finalContent =
- textResult && textResult.trim()
+ normalizedTextResult
? textResult
: `${last.content || ''}${pendingChunk || ''}`;
// Clone the message instead of mutating in place so React can reliably detect state updates.
updated[lastIndex] = { ...last, content: finalContent, isStreaming: false };
- } else if (textResult && textResult.trim()) {
+ } else if (normalizedTextResult) {
+ const lastAssistantText =
+ last && last.type === 'assistant' && !last.isToolUse
+ ? String(last.content || '').trim()
+ : '';
+
+ // Cursor can emit the same final text through both streaming and result payloads.
+ // Skip adding a second assistant bubble when the final text is unchanged.
+ const isDuplicateFinalText = lastAssistantText === normalizedTextResult;
+ if (isDuplicateFinalText) {
+ return updated;
+ }
+
updated.push({
type: resultData.is_error ? 'error' : 'assistant',
content: textResult,
diff --git a/src/components/chat/utils/messageTransforms.ts b/src/components/chat/utils/messageTransforms.ts
index b4a5250..cf38e44 100644
--- a/src/components/chat/utils/messageTransforms.ts
+++ b/src/components/chat/utils/messageTransforms.ts
@@ -34,6 +34,48 @@ const normalizeToolInput = (value: unknown): string => {
}
};
+const CURSOR_INTERNAL_USER_BLOCK_PATTERNS = [
+ /[\s\S]*?<\/user_info>/gi,
+ /[\s\S]*?<\/agent_skills>/gi,
+ /[\s\S]*?<\/available_skills>/gi,
+ /[\s\S]*?<\/environment_context>/gi,
+ /[\s\S]*?<\/environment_info>/gi,
+];
+
+const extractCursorUserQuery = (rawText: string): string => {
+ const userQueryMatches = [...rawText.matchAll(/([\s\S]*?)<\/user_query>/gi)];
+ if (userQueryMatches.length === 0) {
+ return '';
+ }
+
+ return userQueryMatches
+ .map((match) => (match[1] || '').trim())
+ .filter(Boolean)
+ .join('\n')
+ .trim();
+};
+
+const sanitizeCursorUserMessageText = (rawText: string): string => {
+ const decodedText = decodeHtmlEntities(rawText || '').trim();
+ if (!decodedText) {
+ return '';
+ }
+
+ // Cursor stores user-visible text inside and prepends hidden context blocks
+ // (, , etc). We only render the actual query in chat history.
+ const extractedUserQuery = extractCursorUserQuery(decodedText);
+ if (extractedUserQuery) {
+ return extractedUserQuery;
+ }
+
+ let sanitizedText = decodedText;
+ CURSOR_INTERNAL_USER_BLOCK_PATTERNS.forEach((pattern) => {
+ sanitizedText = sanitizedText.replace(pattern, '');
+ });
+
+ return sanitizedText.trim();
+};
+
const toAbsolutePath = (projectPath: string, filePath?: string) => {
if (!filePath) {
return filePath;
@@ -321,6 +363,10 @@ export const convertCursorSessionMessages = (blobs: CursorBlob[], projectPath: s
console.log('Error parsing blob content:', error);
}
+ if (role === 'user') {
+ text = sanitizeCursorUserMessageText(text);
+ }
+
if (text && text.trim()) {
const message: ChatMessage = {
type: role,
diff --git a/src/components/chat/view/ChatInterface.tsx b/src/components/chat/view/ChatInterface.tsx
index 90c1921..79c99d8 100644
--- a/src/components/chat/view/ChatInterface.tsx
+++ b/src/components/chat/view/ChatInterface.tsx
@@ -109,6 +109,7 @@ function ChatInterface({
scrollToBottom,
scrollToBottomAndReset,
handleScroll,
+ loadSessionMessages,
} = useChatSessionState({
selectedProject,
selectedSession,
@@ -197,6 +198,23 @@ function ChatInterface({
setPendingPermissionRequests,
});
+ // On WebSocket reconnect, re-fetch the current session's messages from JSONL so missed
+ // streaming events (e.g. from long tool calls while iOS had the tab backgrounded) are shown.
+ // Also reset isLoading — if the server restarted or the session died mid-stream, the client
+ // would be stuck in "Processing..." forever without this reset.
+ const handleWebSocketReconnect = useCallback(async () => {
+ if (!selectedProject || !selectedSession) return;
+ const provider = (localStorage.getItem('selected-provider') as any) || 'claude';
+ const messages = await loadSessionMessages(selectedProject.name, selectedSession.id, false, provider);
+ if (messages && messages.length > 0) {
+ setChatMessages(messages);
+ }
+ // Reset loading state — if the session is still active, new WebSocket messages will
+ // set it back to true. If it died, this clears the permanent frozen state.
+ setIsLoading(false);
+ setCanAbortSession(false);
+ }, [selectedProject, selectedSession, loadSessionMessages, setChatMessages, setIsLoading, setCanAbortSession]);
+
useChatRealtimeHandlers({
latestMessage,
provider,
@@ -219,6 +237,7 @@ function ChatInterface({
onSessionNotProcessing,
onReplaceTemporarySession,
onNavigateToSession,
+ onWebSocketReconnect: handleWebSocketReconnect,
});
useEffect(() => {
diff --git a/src/components/chat/view/subcomponents/ChatComposer.tsx b/src/components/chat/view/subcomponents/ChatComposer.tsx
index 35bf754..2bf8eb5 100644
--- a/src/components/chat/view/subcomponents/ChatComposer.tsx
+++ b/src/components/chat/view/subcomponents/ChatComposer.tsx
@@ -301,8 +301,7 @@ export default function ChatComposer({
onBlur={() => onInputFocusChange?.(false)}
onInput={onTextareaInput}
placeholder={placeholder}
- disabled={isLoading}
- className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none disabled:opacity-50 sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
+ className="chat-input-placeholder block max-h-[40vh] min-h-[50px] w-full resize-none overflow-y-auto rounded-2xl bg-transparent py-1.5 pl-12 pr-20 text-base leading-6 text-foreground placeholder-muted-foreground/50 transition-all duration-200 focus:outline-none sm:max-h-[300px] sm:min-h-[80px] sm:py-4 sm:pr-40"
style={{ height: '50px' }}
/>
diff --git a/src/components/chat/view/subcomponents/MessageComponent.tsx b/src/components/chat/view/subcomponents/MessageComponent.tsx
index 6542e37..75a6f69 100644
--- a/src/components/chat/view/subcomponents/MessageComponent.tsx
+++ b/src/components/chat/view/subcomponents/MessageComponent.tsx
@@ -1,4 +1,4 @@
-import React, { memo, useMemo } from 'react';
+import { memo, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
import type {
@@ -9,10 +9,10 @@ import type {
} from '../../types/types';
import { formatUsageLimitText } from '../../utils/chatFormatting';
import { getClaudePermissionSuggestion } from '../../utils/chatPermissions';
-import { copyTextToClipboard } from '../../../../utils/clipboard';
import type { Project } from '../../../../types/app';
import { ToolRenderer, shouldHideToolResult } from '../../tools';
import { Markdown } from './Markdown';
+import MessageCopyControl from './MessageCopyControl';
type DiffLine = {
type: string;
@@ -20,7 +20,7 @@ type DiffLine = {
lineNum: number;
};
-interface MessageComponentProps {
+type MessageComponentProps = {
message: ChatMessage;
prevMessage: ChatMessage | null;
createDiff: (oldStr: string, newStr: string) => DiffLine[];
@@ -32,7 +32,7 @@ interface MessageComponentProps {
showThinking?: boolean;
selectedProject?: Project | null;
provider: Provider | string;
-}
+};
type InteractiveOption = {
number: string;
@@ -41,6 +41,7 @@ type InteractiveOption = {
};
type PermissionGrantState = 'idle' | 'granted' | 'error';
+const COPY_HIDDEN_TOOL_NAMES = new Set(['Bash', 'Edit', 'Write', 'ApplyPatch']);
const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, onShowSettings, onGrantToolPermission, autoExpandTools, showRawParameters, showThinking, selectedProject, provider }: MessageComponentProps) => {
const { t } = useTranslation('chat');
@@ -49,18 +50,32 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
(prevMessage.type === 'user') ||
(prevMessage.type === 'tool') ||
(prevMessage.type === 'error'));
- const messageRef = React.useRef(null);
- const [isExpanded, setIsExpanded] = React.useState(false);
+ const messageRef = useRef(null);
+ const [isExpanded, setIsExpanded] = useState(false);
const permissionSuggestion = getClaudePermissionSuggestion(message, provider);
- const [permissionGrantState, setPermissionGrantState] = React.useState('idle');
- const [messageCopied, setMessageCopied] = React.useState(false);
+ const [permissionGrantState, setPermissionGrantState] = useState('idle');
+ const userCopyContent = String(message.content || '');
+ const formattedMessageContent = useMemo(
+ () => formatUsageLimitText(String(message.content || '')),
+ [message.content]
+ );
+ const assistantCopyContent = message.isToolUse
+ ? String(message.displayText || message.content || '')
+ : formattedMessageContent;
+ const isCommandOrFileEditToolResponse = Boolean(
+ message.isToolUse && COPY_HIDDEN_TOOL_NAMES.has(String(message.toolName || ''))
+ );
+ const shouldShowUserCopyControl = message.type === 'user' && userCopyContent.trim().length > 0;
+ const shouldShowAssistantCopyControl = message.type === 'assistant' &&
+ assistantCopyContent.trim().length > 0 &&
+ !isCommandOrFileEditToolResponse;
- React.useEffect(() => {
+ useEffect(() => {
setPermissionGrantState('idle');
}, [permissionSuggestion?.entry, message.toolId]);
- React.useEffect(() => {
+ useEffect(() => {
const node = messageRef.current;
if (!autoExpandTools || !node || !message.isToolUse) return;
@@ -120,43 +135,9 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o