diff --git a/.env.example b/.env.example index 0a237e4..d18b97e 100755 --- a/.env.example +++ b/.env.example @@ -42,4 +42,4 @@ HOST=0.0.0.0 VITE_CONTEXT_WINDOW=160000 CONTEXT_WINDOW=160000 -# VITE_IS_PLATFORM=false + diff --git a/.gitignore b/.gitignore index 2ff70c1..5ee3dbb 100755 --- a/.gitignore +++ b/.gitignore @@ -108,7 +108,7 @@ temp/ .serena/ CLAUDE.md .mcp.json - +.gemini/ # Database files *.db @@ -130,3 +130,11 @@ dev-debug.log # Task files tasks.json tasks/ + +# Translations +!src/i18n/locales/en/tasks.json +!src/i18n/locales/ja/tasks.json +!src/i18n/locales/ru/tasks.json + +# Git worktrees +.worktrees/ \ No newline at end of file diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..ca83e73 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "plugins/starter"] + path = plugins/starter + url = https://github.com/cloudcli-ai/cloudcli-plugin-starter.git diff --git a/.husky/commit-msg b/.husky/commit-msg index 0a4b97d..a78cc75 100644 --- a/.husky/commit-msg +++ b/.husky/commit-msg @@ -1 +1 @@ -npx --no -- commitlint --edit $1 +npx commitlint --edit $1 diff --git a/CHANGELOG.md b/CHANGELOG.md index f55cd90..4c78f59 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,50 @@ All notable changes to CloudCLI UI will be documented in this file. +## [1.25.2](https://github.com/siteboon/claudecodeui/compare/v1.25.0...v1.25.2) (2026-03-11) + +### New Features + +* **i18n:** localize plugin settings for all languages ([#515](https://github.com/siteboon/claudecodeui/issues/515)) ([621853c](https://github.com/siteboon/claudecodeui/commit/621853cbfb4233b34cb8cc2e1ed10917ba424352)) + +### Bug Fixes + +* codeql user value provided path validation ([aaa14b9](https://github.com/siteboon/claudecodeui/commit/aaa14b9fc0b9b51c4fb9d1dba40fada7cbbe0356)) +* numerous bugs ([#528](https://github.com/siteboon/claudecodeui/issues/528)) ([a77f213](https://github.com/siteboon/claudecodeui/commit/a77f213dd5d0b2538dea091ab8da6e55d2002f2f)) +* **security:** disable executable gray-matter frontmatter in commands ([b9c902b](https://github.com/siteboon/claudecodeui/commit/b9c902b016f411a942c8707dd07d32b60bad087c)) +* session reconnect catch-up, always-on input, frozen session recovery ([#524](https://github.com/siteboon/claudecodeui/issues/524)) ([4d8fb6e](https://github.com/siteboon/claudecodeui/commit/4d8fb6e30aa03d7cdb92bd62b7709422f9d08e32)) + +### Refactoring + +* new settings page design and new pill component ([8ddeeb0](https://github.com/siteboon/claudecodeui/commit/8ddeeb0ce8d0642560bd3fa149236011dc6e3707)) + +## [1.25.0](https://github.com/siteboon/claudecodeui/compare/v1.24.0...v1.25.0) (2026-03-10) + +### New Features + +* add copy as text or markdown feature for assistant messages ([#519](https://github.com/siteboon/claudecodeui/issues/519)) ([1dc2a20](https://github.com/siteboon/claudecodeui/commit/1dc2a205dc2a3cbf960625d7669c7c63a2b6905f)) +* add full Russian language support; update Readme.md files, and .gitignore update ([#514](https://github.com/siteboon/claudecodeui/issues/514)) ([c7dcba8](https://github.com/siteboon/claudecodeui/commit/c7dcba8d9117e84db8aac7d8a7bf6a3aa683e115)) +* new plugin system ([#489](https://github.com/siteboon/claudecodeui/issues/489)) ([8afb46a](https://github.com/siteboon/claudecodeui/commit/8afb46af2e5514c9284030367281793fbb014e4f)) + +### Bug Fixes + +* resolve duplicate key issue when rendering model options ([#520](https://github.com/siteboon/claudecodeui/issues/520)) ([9bceab9](https://github.com/siteboon/claudecodeui/commit/9bceab9e1a6e063b0b4f934ed2d9f854fcc9c6a4)) + +### Maintenance + +* add plugins section in readme ([e581a0e](https://github.com/siteboon/claudecodeui/commit/e581a0e1ccd59fd7ec7306ca76a13e73d7c674c1)) + +## [1.24.0](https://github.com/siteboon/claudecodeui/compare/v1.23.2...v1.24.0) (2026-03-09) + +### New Features + +* add full-text search across conversations ([#482](https://github.com/siteboon/claudecodeui/issues/482)) ([3950c0e](https://github.com/siteboon/claudecodeui/commit/3950c0e47f41e93227af31494690818d45c8bc7a)) + +### Bug Fixes + +* **git:** prevent shell injection in git routes ([86c33c1](https://github.com/siteboon/claudecodeui/commit/86c33c1c0cb34176725a38f46960213714fc3e04)) +* replace getDatabase with better-sqlite3 db in getGithubTokenById ([#501](https://github.com/siteboon/claudecodeui/issues/501)) ([cb4fd79](https://github.com/siteboon/claudecodeui/commit/cb4fd795c938b1cc86d47f401973bfccdd68fdee)) + ## [1.23.2](https://github.com/siteboon/claudecodeui/compare/v1.22.1...v1.23.2) (2026-03-06) ### New Features diff --git a/README.ja.md b/README.ja.md index 0fcce17..67d53f0 100644 --- a/README.ja.md +++ b/README.ja.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) 向けのデスクトップ・モバイル UI です。ローカルまたはリモートで使用して、Claude Code、Cursor、Codex のアクティブなプロジェクトやセッションを確認し、どこからでも(モバイルやデスクトップから)変更を加えることができます。どこでも使える適切なインターフェースを提供します。 -
English · 한국어 · 中文
+
English · Русский · 한국어 · 中文 · 日本語
## スクリーンショット @@ -193,8 +193,8 @@ npm run dev Claude Code の全機能を使用するには、手動でツールを有効にする必要があります: 1. **ツール設定を開く** - サイドバーの歯車アイコンをクリック -3. **選択的に有効化** - 必要なツールのみを有効にする -4. **設定を適用** - 環境設定はローカルに保存されます +2. **選択的に有効化** - 必要なツールのみを有効にする +3. **設定を適用** - 環境設定はローカルに保存されます
diff --git a/README.ko.md b/README.ko.md index b9c8a75..00250f9 100644 --- a/README.ko.md +++ b/README.ko.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)를 위한 데스크톱 및 모바일 UI입니다. 로컬 또는 원격으로 사용하여 Claude Code, Cursor 또는 Codex의 활성 프로젝트와 세션을 확인하고, 어디서든(모바일 또는 데스크톱) 변경할 수 있습니다. 어디서든 작동하는 적절한 인터페이스를 제공합니다. -
English · 中文 · 日本語
+
English · Русский · 한국어 · 中文 · 日本語
## 스크린샷 @@ -193,8 +193,8 @@ npm run dev Claude Code의 전체 기능을 사용하려면 수동으로 도구를 활성화해야 합니다: 1. **도구 설정 열기** - 사이드바의 톱니바퀴 아이콘을 클릭 -3. **선택적으로 활성화** - 필요한 도구만 활성화 -4. **설정 적용** - 환경설정은 로컬에 저장됩니다 +2. **선택적으로 활성화** - 필요한 도구만 활성화 +3. **설정 적용** - 환경설정은 로컬에 저장됩니다
diff --git a/README.md b/README.md index fc985a5..3eb4b0f 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,23 @@
CloudCLI UI

Cloud CLI (aka Claude Code UI)

+

A desktop and mobile UI for Claude Code, Cursor CLI, Codex, and Gemini-CLI.
Use it locally or remotely to view your active projects and sessions from everywhere.

- -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. -

- CloudCLI Cloud · Discord · Bug Reports · Contributing + CloudCLI Cloud · Documentation · Discord · Bug Reports · Contributing

- Join our Discord + CloudCLI Cloud + Join our Discord +

siteboon%2Fclaudecodeui | Trendshift

-
English · 한국어 · 中文 · 日本語
+
English · Русский · 한국어 · 中文 · 日本語
+ +--- ## Screenshots @@ -41,7 +43,7 @@ A desktop and mobile UI for [Claude Code](https://docs.anthropic.com/en/docs/cla

CLI Selection

CLI Selection
-Select between Claude Code, Cursor CLI and Codex +Select between Claude Code, Gemini, Cursor CLI and Codex @@ -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 @@ +
+ CloudCLI UI +

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 · Сообщить об ошибке · Участие в разработке +

+ +

+ Join our Discord + siteboon%2Fclaudecodeui | Trendshift +

+ +
English · Русский · 한국어 · 中文 · 日本語
+ +## Скриншоты + +
+ + + + + + + + + +
+

Версия для десктопа

+Desktop Interface +
+Основной интерфейс с обзором проекта и чатом +
+

Мобильный режим

+Mobile Interface +
+Адаптивный мобильный интерфейс с сенсорной навигацией +
+

Выбор CLI

+CLI Selection +
+Выбор между 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. **Примените настройки** - предпочтения сохраняются локально + +
+ +![Tools Settings Modal](public/screenshots/tools-modal.png) +*Окно настройки инструментов - включайте только то, что вам нужно* + +
+ +**Рекомендуемый подход**: начните с базовых инструментов и добавляйте остальные по мере необходимости. Эти настройки всегда можно поменять позже. + +--- +## 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 中的活跃项目和会话,并从任何地方(移动端或桌面端)对它们进行修改。这为您提供了一个在任何地方都能正常使用的合适界面。 -
English · 한국어 · 日本語
+
English · Русский · 한국어 · 中文 · 日本語
## 截图 @@ -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 ( ); })} + + {/* "More" button — only shown when there are enabled plugins */} + {hasPlugins && ( +
+ + + {/* Popover menu */} + {moreOpen && ( +
+ {enabledPlugins.map((p) => { + const Icon = PLUGIN_ICON_MAP[p.icon] || Puzzle; + const isActive = activeTab === `plugin:${p.name}`; + + return ( + + ); + })} +
+ )} +
+ )}
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
)}
- + {shouldShowUserCopyControl && ( + + )} {formattedTime}
@@ -430,7 +411,7 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o )} {(() => { - const content = formatUsageLimitText(String(message.content || '')); + const content = formattedMessageContent; // Detect if content is pure JSON (starts with { or [) const trimmedContent = content.trim(); @@ -476,9 +457,12 @@ const MessageComponent = memo(({ message, prevMessage, createDiff, onFileOpen, o
)} - {!isGrouped && ( -
- {formattedTime} + {(shouldShowAssistantCopyControl || !isGrouped) && ( +
+ {shouldShowAssistantCopyControl && ( + + )} + {!isGrouped && {formattedTime}}
)}
diff --git a/src/components/chat/view/subcomponents/MessageCopyControl.tsx b/src/components/chat/view/subcomponents/MessageCopyControl.tsx new file mode 100644 index 0000000..aeacd45 --- /dev/null +++ b/src/components/chat/view/subcomponents/MessageCopyControl.tsx @@ -0,0 +1,215 @@ +import { useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { copyTextToClipboard } from '../../../../utils/clipboard'; + +const COPY_SUCCESS_TIMEOUT_MS = 2000; + +type CopyFormat = 'text' | 'markdown'; + +type CopyFormatOption = { + format: CopyFormat; + label: string; +}; + +// Converts markdown into readable plain text for "Copy as text". +const convertMarkdownToPlainText = (markdown: string): string => { + let plainText = markdown.replace(/\r\n/g, '\n'); + const codeBlocks: string[] = []; + plainText = plainText.replace(/```[\w-]*\n([\s\S]*?)```/g, (_match, code: string) => { + const placeholder = `@@CODEBLOCK${codeBlocks.length}@@`; + codeBlocks.push(code.replace(/\n$/, '')); + return placeholder; + }); + plainText = plainText.replace(/`([^`]+)`/g, '$1'); + plainText = plainText.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); + plainText = plainText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + plainText = plainText.replace(/^>\s?/gm, ''); + plainText = plainText.replace(/^#{1,6}\s+/gm, ''); + plainText = plainText.replace(/^[-*+]\s+/gm, ''); + plainText = plainText.replace(/^\d+\.\s+/gm, ''); + plainText = plainText.replace(/(\*\*|__)(.*?)\1/g, '$2'); + plainText = plainText.replace(/(\*|_)(.*?)\1/g, '$2'); + plainText = plainText.replace(/~~(.*?)~~/g, '$1'); + plainText = plainText.replace(/<\/?[^>]+(>|$)/g, ''); + plainText = plainText.replace(/\n{3,}/g, '\n\n'); + plainText = plainText.replace(/@@CODEBLOCK(\d+)@@/g, (_match, index: string) => codeBlocks[Number(index)] ?? ''); + return plainText.trim(); +}; + +const MessageCopyControl = ({ + content, + messageType, +}: { + content: string; + messageType: 'user' | 'assistant'; +}) => { + const { t } = useTranslation('chat'); + const canSelectCopyFormat = messageType === 'assistant'; + const defaultFormat: CopyFormat = canSelectCopyFormat ? 'markdown' : 'text'; + const [selectedFormat, setSelectedFormat] = useState(defaultFormat); + const [copied, setCopied] = useState(false); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const dropdownRef = useRef(null); + const copyFeedbackTimerRef = useRef | null>(null); + + const copyFormatOptions: CopyFormatOption[] = useMemo( + () => [ + { + format: 'markdown', + label: t('copyMessage.copyAsMarkdown', { defaultValue: 'Copy as markdown' }), + }, + { + format: 'text', + label: t('copyMessage.copyAsText', { defaultValue: 'Copy as text' }), + }, + ], + [t] + ); + + const selectedFormatTag = selectedFormat === 'markdown' + ? t('copyMessage.markdownShort', { defaultValue: 'MD' }) + : t('copyMessage.textShort', { defaultValue: 'TXT' }); + + const copyPayload = useMemo(() => { + if (selectedFormat === 'markdown') { + return content; + } + return convertMarkdownToPlainText(content); + }, [content, selectedFormat]); + + useEffect(() => { + setSelectedFormat(defaultFormat); + setIsDropdownOpen(false); + }, [defaultFormat]); + + useEffect(() => { + // Close the dropdown when clicking anywhere outside this control. + const closeOnOutsideClick = (event: MouseEvent) => { + if (!isDropdownOpen) return; + const target = event.target as Node; + if (dropdownRef.current && !dropdownRef.current.contains(target)) { + setIsDropdownOpen(false); + } + }; + + window.addEventListener('mousedown', closeOnOutsideClick); + return () => { + window.removeEventListener('mousedown', closeOnOutsideClick); + }; + }, [isDropdownOpen]); + + useEffect(() => { + return () => { + if (copyFeedbackTimerRef.current) { + clearTimeout(copyFeedbackTimerRef.current); + } + }; + }, []); + + const handleCopyClick = async () => { + if (!copyPayload.trim()) return; + const didCopy = await copyTextToClipboard(copyPayload); + if (!didCopy) return; + + setCopied(true); + if (copyFeedbackTimerRef.current) { + clearTimeout(copyFeedbackTimerRef.current); + } + copyFeedbackTimerRef.current = setTimeout(() => { + setCopied(false); + }, COPY_SUCCESS_TIMEOUT_MS); + }; + + const handleFormatChange = (format: CopyFormat) => { + setSelectedFormat(format); + setIsDropdownOpen(false); + }; + + const toneClass = messageType === 'user' + ? 'text-blue-100 hover:text-white' + : 'text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300'; + const copyTitle = copied ? t('copyMessage.copied') : t('copyMessage.copy'); + const rootClassName = canSelectCopyFormat + ? 'relative flex min-w-0 flex-1 items-center gap-0.5 sm:min-w-max sm:flex-none sm:w-auto' + : 'relative flex items-center gap-0.5'; + + return ( +
+ + + {canSelectCopyFormat && ( + <> + + + {isDropdownOpen && ( +
+ {copyFormatOptions.map((option) => { + const isSelected = option.format === selectedFormat; + return ( + + ); + })} +
+ )} + + )} +
+ ); +}; + +export default MessageCopyControl; diff --git a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx index 646e26b..792b12c 100644 --- a/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx +++ b/src/components/chat/view/subcomponents/ProviderSelectionEmptyState.tsx @@ -1,12 +1,17 @@ -import React from 'react'; -import { Check, ChevronDown } from 'lucide-react'; -import { useTranslation } from 'react-i18next'; -import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; -import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS, GEMINI_MODELS } from '../../../../../shared/modelConstants'; -import type { ProjectSession, SessionProvider } from '../../../../types/app'; -import { NextTaskBanner } from '../../../task-master'; +import React from "react"; +import { Check, ChevronDown } from "lucide-react"; +import { useTranslation } from "react-i18next"; +import SessionProviderLogo from "../../../llm-logo-provider/SessionProviderLogo"; +import { + CLAUDE_MODELS, + CURSOR_MODELS, + CODEX_MODELS, + GEMINI_MODELS, +} from "../../../../../shared/modelConstants"; +import type { ProjectSession, SessionProvider } from "../../../../types/app"; +import { NextTaskBanner } from "../../../task-master"; -interface ProviderSelectionEmptyStateProps { +type ProviderSelectionEmptyStateProps = { selectedSession: ProjectSession | null; currentSessionId: string | null; provider: SessionProvider; @@ -24,7 +29,7 @@ interface ProviderSelectionEmptyStateProps { isTaskMasterInstalled: boolean | null; onShowAllTasks?: (() => void) | null; setInput: React.Dispatch>; -} +}; type ProviderDef = { id: SessionProvider; @@ -37,50 +42,56 @@ type ProviderDef = { const PROVIDERS: ProviderDef[] = [ { - id: 'claude', - name: 'Claude Code', - infoKey: 'providerSelection.providerInfo.anthropic', - accent: 'border-primary', - ring: 'ring-primary/15', - check: 'bg-primary text-primary-foreground', + id: "claude", + name: "Claude Code", + infoKey: "providerSelection.providerInfo.anthropic", + accent: "border-primary", + ring: "ring-primary/15", + check: "bg-primary text-primary-foreground", }, { - id: 'cursor', - name: 'Cursor', - infoKey: 'providerSelection.providerInfo.cursorEditor', - accent: 'border-violet-500 dark:border-violet-400', - ring: 'ring-violet-500/15', - check: 'bg-violet-500 text-white', + id: "cursor", + name: "Cursor", + infoKey: "providerSelection.providerInfo.cursorEditor", + accent: "border-violet-500 dark:border-violet-400", + ring: "ring-violet-500/15", + check: "bg-violet-500 text-white", }, { - id: 'codex', - name: 'Codex', - infoKey: 'providerSelection.providerInfo.openai', - accent: 'border-emerald-600 dark:border-emerald-400', - ring: 'ring-emerald-600/15', - check: 'bg-emerald-600 dark:bg-emerald-500 text-white', + id: "codex", + name: "Codex", + infoKey: "providerSelection.providerInfo.openai", + accent: "border-emerald-600 dark:border-emerald-400", + ring: "ring-emerald-600/15", + check: "bg-emerald-600 dark:bg-emerald-500 text-white", }, { - id: 'gemini', - name: 'Gemini', - infoKey: 'providerSelection.providerInfo.google', - accent: 'border-blue-500 dark:border-blue-400', - ring: 'ring-blue-500/15', - check: 'bg-blue-500 text-white', + id: "gemini", + name: "Gemini", + infoKey: "providerSelection.providerInfo.google", + accent: "border-blue-500 dark:border-blue-400", + ring: "ring-blue-500/15", + check: "bg-blue-500 text-white", }, ]; function getModelConfig(p: SessionProvider) { - if (p === 'claude') return CLAUDE_MODELS; - if (p === 'codex') return CODEX_MODELS; - if (p === 'gemini') return GEMINI_MODELS; + if (p === "claude") return CLAUDE_MODELS; + if (p === "codex") return CODEX_MODELS; + if (p === "gemini") return GEMINI_MODELS; return CURSOR_MODELS; } -function getModelValue(p: SessionProvider, c: string, cu: string, co: string, g: string) { - if (p === 'claude') return c; - if (p === 'codex') return co; - if (p === 'gemini') return g; +function getModelValue( + p: SessionProvider, + c: string, + cu: string, + co: string, + g: string, +) { + if (p === "claude") return c; + if (p === "codex") return co; + if (p === "gemini") return g; return cu; } @@ -103,24 +114,41 @@ export default function ProviderSelectionEmptyState({ onShowAllTasks, setInput, }: ProviderSelectionEmptyStateProps) { - const { t } = useTranslation('chat'); - const nextTaskPrompt = t('tasks.nextTaskPrompt', { defaultValue: 'Start the next task' }); + const { t } = useTranslation("chat"); + const nextTaskPrompt = t("tasks.nextTaskPrompt", { + defaultValue: "Start the next task", + }); const selectProvider = (next: SessionProvider) => { setProvider(next); - localStorage.setItem('selected-provider', next); + localStorage.setItem("selected-provider", next); setTimeout(() => textareaRef.current?.focus(), 100); }; const handleModelChange = (value: string) => { - if (provider === 'claude') { setClaudeModel(value); localStorage.setItem('claude-model', value); } - else if (provider === 'codex') { setCodexModel(value); localStorage.setItem('codex-model', value); } - else if (provider === 'gemini') { setGeminiModel(value); localStorage.setItem('gemini-model', value); } - else { setCursorModel(value); localStorage.setItem('cursor-model', value); } + if (provider === "claude") { + setClaudeModel(value); + localStorage.setItem("claude-model", value); + } else if (provider === "codex") { + setCodexModel(value); + localStorage.setItem("codex-model", value); + } else if (provider === "gemini") { + setGeminiModel(value); + localStorage.setItem("gemini-model", value); + } else { + setCursorModel(value); + localStorage.setItem("cursor-model", value); + } }; const modelConfig = getModelConfig(provider); - const currentModel = getModelValue(provider, claudeModel, cursorModel, codexModel, geminiModel); + const currentModel = getModelValue( + provider, + claudeModel, + cursorModel, + codexModel, + geminiModel, + ); /* ── New session — provider picker ── */ if (!selectedSession && !currentSessionId) { @@ -130,10 +158,10 @@ export default function ProviderSelectionEmptyState({ {/* Heading */}

- {t('providerSelection.title')} + {t("providerSelection.title")}

- {t('providerSelection.description')} + {t("providerSelection.description")}

@@ -149,23 +177,30 @@ export default function ProviderSelectionEmptyState({ relative flex flex-col items-center gap-2.5 rounded-xl border-[1.5px] px-2 pb-4 pt-5 transition-all duration-150 active:scale-[0.97] - ${active - ? `${p.accent} ${p.ring} bg-card shadow-sm ring-2` - : 'border-border bg-card/60 hover:border-border/80 hover:bg-card' + ${ + active + ? `${p.accent} ${p.ring} bg-card shadow-sm ring-2` + : "border-border bg-card/60 hover:border-border/80 hover:bg-card" } `} >
-

{p.name}

-

{t(p.infoKey)}

+

+ {p.name} +

+

+ {t(p.infoKey)} +

{/* Check badge */} {active && ( -
+
)} @@ -175,9 +210,13 @@ export default function ProviderSelectionEmptyState({
{/* Model picker — appears after provider is chosen */} -
+
- {t('providerSelection.selectModel')} + + {t("providerSelection.selectModel")} +
@@ -196,10 +239,18 @@ export default function ProviderSelectionEmptyState({

{ { - claude: t('providerSelection.readyPrompt.claude', { model: claudeModel }), - cursor: t('providerSelection.readyPrompt.cursor', { model: cursorModel }), - codex: t('providerSelection.readyPrompt.codex', { model: codexModel }), - gemini: t('providerSelection.readyPrompt.gemini', { model: geminiModel }), + claude: t("providerSelection.readyPrompt.claude", { + model: claudeModel, + }), + cursor: t("providerSelection.readyPrompt.cursor", { + model: cursorModel, + }), + codex: t("providerSelection.readyPrompt.codex", { + model: codexModel, + }), + gemini: t("providerSelection.readyPrompt.gemini", { + model: geminiModel, + }), }[provider] }

@@ -208,7 +259,10 @@ export default function ProviderSelectionEmptyState({ {/* Task banner */} {provider && tasksEnabled && isTaskMasterInstalled && (
- setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} /> + setInput(nextTaskPrompt)} + onShowAllTasks={onShowAllTasks} + />
)}
@@ -221,12 +275,19 @@ export default function ProviderSelectionEmptyState({ return (
-

{t('session.continue.title')}

-

{t('session.continue.description')}

+

+ {t("session.continue.title")} +

+

+ {t("session.continue.description")} +

{tasksEnabled && isTaskMasterInstalled && (
- setInput(nextTaskPrompt)} onShowAllTasks={onShowAllTasks} /> + setInput(nextTaskPrompt)} + onShowAllTasks={onShowAllTasks} + />
)}
diff --git a/src/components/git-panel/constants/constants.ts b/src/components/git-panel/constants/constants.ts index 5defa41..420f955 100644 --- a/src/components/git-panel/constants/constants.ts +++ b/src/components/git-panel/constants/constants.ts @@ -31,6 +31,7 @@ export const CONFIRMATION_TITLES: Record = { pull: 'Confirm Pull', push: 'Confirm Push', publish: 'Publish Branch', + revertLocalCommit: 'Revert Local Commit', }; export const CONFIRMATION_ACTION_LABELS: Record = { @@ -40,6 +41,7 @@ export const CONFIRMATION_ACTION_LABELS: Record = { pull: 'Pull', push: 'Push', publish: 'Publish', + revertLocalCommit: 'Revert Commit', }; export const CONFIRMATION_BUTTON_CLASSES: Record = { @@ -49,6 +51,7 @@ export const CONFIRMATION_BUTTON_CLASSES: Record = { pull: 'bg-green-600 hover:bg-green-700', push: 'bg-orange-600 hover:bg-orange-700', publish: 'bg-purple-600 hover:bg-purple-700', + revertLocalCommit: 'bg-yellow-600 hover:bg-yellow-700', }; export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record = { @@ -58,6 +61,7 @@ export const CONFIRMATION_ICON_CONTAINER_CLASSES: Record = { @@ -67,4 +71,5 @@ export const CONFIRMATION_ICON_CLASSES: Record = { pull: 'text-yellow-600 dark:text-yellow-400', push: 'text-yellow-600 dark:text-yellow-400', publish: 'text-yellow-600 dark:text-yellow-400', + revertLocalCommit: 'text-yellow-600 dark:text-yellow-400', }; diff --git a/src/components/git-panel/hooks/useRevertLocalCommit.ts b/src/components/git-panel/hooks/useRevertLocalCommit.ts new file mode 100644 index 0000000..3c3ea91 --- /dev/null +++ b/src/components/git-panel/hooks/useRevertLocalCommit.ts @@ -0,0 +1,48 @@ +import { useCallback, useState } from 'react'; +import { authenticatedFetch } from '../../../utils/api'; +import type { GitOperationResponse } from '../types/types'; + +type UseRevertLocalCommitOptions = { + projectName: string | null; + onSuccess?: () => void; +}; + +async function readJson(response: Response): Promise { + return (await response.json()) as T; +} + +export function useRevertLocalCommit({ projectName, onSuccess }: UseRevertLocalCommitOptions) { + const [isRevertingLocalCommit, setIsRevertingLocalCommit] = useState(false); + + const revertLatestLocalCommit = useCallback(async () => { + if (!projectName) { + return; + } + + setIsRevertingLocalCommit(true); + try { + const response = await authenticatedFetch('/api/git/revert-local-commit', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ project: projectName }), + }); + const data = await readJson(response); + + if (!data.success) { + console.error('Revert local commit failed:', data.error || data.details || 'Unknown error'); + return; + } + + onSuccess?.(); + } catch (error) { + console.error('Error reverting local commit:', error); + } finally { + setIsRevertingLocalCommit(false); + } + }, [onSuccess, projectName]); + + return { + isRevertingLocalCommit, + revertLatestLocalCommit, + }; +} diff --git a/src/components/git-panel/types/types.ts b/src/components/git-panel/types/types.ts index 452f779..c8188e9 100644 --- a/src/components/git-panel/types/types.ts +++ b/src/components/git-panel/types/types.ts @@ -3,7 +3,7 @@ import type { Project } from '../../../types/app'; export type GitPanelView = 'changes' | 'history'; export type FileStatusCode = 'M' | 'A' | 'D' | 'U'; export type GitStatusFileGroup = 'modified' | 'added' | 'deleted' | 'untracked'; -export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish'; +export type ConfirmActionType = 'discard' | 'delete' | 'commit' | 'pull' | 'push' | 'publish' | 'revertLocalCommit'; export type FileDiffInfo = { old_string: string; diff --git a/src/components/git-panel/view/GitPanel.tsx b/src/components/git-panel/view/GitPanel.tsx index 7f071c0..d670f65 100644 --- a/src/components/git-panel/view/GitPanel.tsx +++ b/src/components/git-panel/view/GitPanel.tsx @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; import { useGitPanelController } from '../hooks/useGitPanelController'; +import { useRevertLocalCommit } from '../hooks/useRevertLocalCommit'; import type { ConfirmationRequest, GitPanelProps, GitPanelView } from '../types/types'; import ChangesView from '../view/changes/ChangesView'; import HistoryView from '../view/history/HistoryView'; @@ -49,6 +50,11 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen onFileOpen, }); + const { isRevertingLocalCommit, revertLatestLocalCommit } = useRevertLocalCommit({ + projectName: selectedProject?.name ?? null, + onSuccess: refreshAll, + }); + const executeConfirmedAction = useCallback(async () => { if (!confirmAction) { return; @@ -85,7 +91,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen isPulling={isPulling} isPushing={isPushing} isPublishing={isPublishing} + isRevertingLocalCommit={isRevertingLocalCommit} onRefresh={refreshAll} + onRevertLocalCommit={revertLatestLocalCommit} onSwitchBranch={switchBranch} onCreateBranch={createBranch} onFetch={handleFetch} @@ -107,7 +115,9 @@ export default function GitPanel({ selectedProject, isMobile = false, onFileOpen {activeView === 'changes' && ( void; + onRevertLocalCommit: () => Promise; onSwitchBranch: (branchName: string) => Promise; onCreateBranch: (branchName: string) => Promise; onFetch: () => Promise; @@ -35,7 +37,9 @@ export default function GitPanelHeader({ isPulling, isPushing, isPublishing, + isRevertingLocalCommit, onRefresh, + onRevertLocalCommit, onSwitchBranch, onCreateBranch, onFetch, @@ -88,6 +92,14 @@ export default function GitPanelHeader({ }); }; + const requestRevertLocalCommitConfirmation = () => { + onRequestConfirmation({ + type: 'revertLocalCommit', + message: 'Revert the latest local commit? This removes the commit but keeps its changes staged.', + onConfirm: onRevertLocalCommit, + }); + }; + const handleSwitchBranch = async (branchName: string) => { try { const success = await onSwitchBranch(branchName); @@ -240,6 +252,17 @@ export default function GitPanelHeader({ )} + + + {tab.kind === 'builtin' ? ( + + ) : ( + + )} + {displayLabel} + ); })} -
+ ); } diff --git a/src/components/main-content/view/subcomponents/MainContentTitle.tsx b/src/components/main-content/view/subcomponents/MainContentTitle.tsx index 698796f..6cc88ba 100644 --- a/src/components/main-content/view/subcomponents/MainContentTitle.tsx +++ b/src/components/main-content/view/subcomponents/MainContentTitle.tsx @@ -1,6 +1,7 @@ import { useTranslation } from 'react-i18next'; import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo'; import type { AppTab, Project, ProjectSession } from '../../../../types/app'; +import { usePlugins } from '../../../../contexts/PluginsContext'; type MainContentTitleProps = { activeTab: AppTab; @@ -9,7 +10,11 @@ type MainContentTitleProps = { shouldShowTasksTab: boolean; }; -function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string) { +function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: string) => string, pluginDisplayName?: string) { + if (activeTab.startsWith('plugin:') && pluginDisplayName) { + return pluginDisplayName; + } + if (activeTab === 'files') { return t('mainContent.projectFiles'); } @@ -40,6 +45,11 @@ export default function MainContentTitle({ shouldShowTasksTab, }: MainContentTitleProps) { const { t } = useTranslation(); + const { plugins } = usePlugins(); + + const pluginDisplayName = activeTab.startsWith('plugin:') + ? plugins.find((p) => p.name === activeTab.replace('plugin:', ''))?.displayName + : undefined; const showSessionIcon = activeTab === 'chat' && Boolean(selectedSession); const showChatNewSession = activeTab === 'chat' && !selectedSession; @@ -68,7 +78,7 @@ export default function MainContentTitle({ ) : (

- {getTabTitle(activeTab, shouldShowTasksTab, t)} + {getTabTitle(activeTab, shouldShowTasksTab, t, pluginDisplayName)}

{selectedProject.displayName}
diff --git a/src/components/plugins/view/PluginIcon.tsx b/src/components/plugins/view/PluginIcon.tsx new file mode 100644 index 0000000..fd59dbb --- /dev/null +++ b/src/components/plugins/view/PluginIcon.tsx @@ -0,0 +1,44 @@ +import { useState, useEffect } from 'react'; +import { authenticatedFetch } from '../../../utils/api'; + +type Props = { + pluginName: string; + iconFile: string; + className?: string; +}; + +// Module-level cache so repeated renders don't re-fetch +const svgCache = new Map(); + +export default function PluginIcon({ pluginName, iconFile, className }: Props) { + const url = iconFile + ? `/api/plugins/${encodeURIComponent(pluginName)}/assets/${encodeURIComponent(iconFile)}` + : ''; + const [svg, setSvg] = useState(url ? (svgCache.get(url) ?? null) : null); + + useEffect(() => { + if (!url || svgCache.has(url)) return; + authenticatedFetch(url) + .then((r) => { + if (!r.ok) return; + return r.text(); + }) + .then((text) => { + if (text && text.trimStart().startsWith(' {}); + }, [url]); + + if (!svg) return ; + + return ( + + ); +} diff --git a/src/components/plugins/view/PluginSettingsTab.tsx b/src/components/plugins/view/PluginSettingsTab.tsx new file mode 100644 index 0000000..81d947a --- /dev/null +++ b/src/components/plugins/view/PluginSettingsTab.tsx @@ -0,0 +1,456 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Trash2, RefreshCw, GitBranch, Loader2, ServerCrash, ShieldAlert, ExternalLink, BookOpen, Download, BarChart3 } from 'lucide-react'; +import { usePlugins } from '../../../contexts/PluginsContext'; +import type { Plugin } from '../../../contexts/PluginsContext'; +import PluginIcon from './PluginIcon'; + +const STARTER_PLUGIN_URL = 'https://github.com/cloudcli-ai/cloudcli-plugin-starter'; + +/* ─── Toggle Switch ─────────────────────────────────────────────────────── */ +function ToggleSwitch({ checked, onChange, ariaLabel }: { checked: boolean; onChange: (v: boolean) => void; ariaLabel: string }) { + return ( +