mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-15 10:01:31 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31f28a2c18 | ||
|
|
8ff5f35c05 | ||
|
|
641304242d | ||
|
|
c3599cd2c4 | ||
|
|
9b11c034d9 | ||
|
|
b6d19201b6 | ||
|
|
4a569725da | ||
|
|
6ce3306947 | ||
|
|
d0dd007d0f | ||
|
|
13e97e2c71 | ||
|
|
c7a5baf147 | ||
|
|
e2459cb0f8 |
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -20,14 +20,14 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
id-token: write
|
id-token: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
token: ${{ secrets.RELEASE_PAT }}
|
token: ${{ secrets.RELEASE_PAT }}
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 20
|
node-version: 22
|
||||||
registry-url: https://registry.npmjs.org
|
registry-url: https://registry.npmjs.org
|
||||||
|
|
||||||
- name: git config
|
- name: git config
|
||||||
|
|||||||
26
CHANGELOG.md
26
CHANGELOG.md
@@ -3,6 +3,32 @@
|
|||||||
All notable changes to CloudCLI UI will be documented in this file.
|
All notable changes to CloudCLI UI will be documented in this file.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.29.2](https://github.com/siteboon/claudecodeui/compare/v1.29.1...v1.29.2) (2026-04-14)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **sandbox:** use backgrounded sbx run to keep sandbox alive ([9b11c03](https://github.com/siteboon/claudecodeui/commit/9b11c034d9a19710a23b56c62dcf07c21a17bd97))
|
||||||
|
|
||||||
|
## [1.29.1](https://github.com/siteboon/claudecodeui/compare/v1.29.0...v1.29.1) (2026-04-14)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* add latest tag to docker npx command and change the detach mode to work without spawn ([4a56972](https://github.com/siteboon/claudecodeui/commit/4a569725dae320a505753359d8edfd8ca79f0fd7))
|
||||||
|
|
||||||
|
## [1.29.0](https://github.com/siteboon/claudecodeui/compare/v1.28.1...v1.29.0) (2026-04-14)
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
|
||||||
|
* adding docker sandbox environments ([13e97e2](https://github.com/siteboon/claudecodeui/commit/13e97e2c71254de7a60afb5495b21064c4bc4241))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **thinking-mode:** fix dropdown positioning ([#646](https://github.com/siteboon/claudecodeui/issues/646)) ([c7a5baf](https://github.com/siteboon/claudecodeui/commit/c7a5baf1479404bd40e23aa58bd9f677df9a04c6))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* update release flow node version ([e2459cb](https://github.com/siteboon/claudecodeui/commit/e2459cb0f8b35f54827778a7b444e6c3ca326506))
|
||||||
|
|
||||||
## [1.28.1](https://github.com/siteboon/claudecodeui/compare/v1.28.0...v1.28.1) (2026-04-10)
|
## [1.28.1](https://github.com/siteboon/claudecodeui/compare/v1.28.0...v1.28.1) (2026-04-10)
|
||||||
|
|
||||||
### New Features
|
### New Features
|
||||||
|
|||||||
11
README.de.md
11
README.de.md
@@ -76,6 +76,8 @@ Der schnellste Einstieg – keine lokale Einrichtung erforderlich. Erhalte eine
|
|||||||
|
|
||||||
### Self-Hosted (Open Source)
|
### Self-Hosted (Open Source)
|
||||||
|
|
||||||
|
#### npm
|
||||||
|
|
||||||
CloudCLI UI sofort mit **npx** ausprobieren (erfordert **Node.js** v22+):
|
CloudCLI UI sofort mit **npx** ausprobieren (erfordert **Node.js** v22+):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -93,6 +95,15 @@ cloudcli
|
|||||||
|
|
||||||
Die **[Dokumentation →](https://cloudcli.ai/docs)** enthält weitere Konfigurationsoptionen, PM2, Remote-Server-Einrichtung und mehr.
|
Die **[Dokumentation →](https://cloudcli.ai/docs)** enthält weitere Konfigurationsoptionen, PM2, Remote-Server-Einrichtung und mehr.
|
||||||
|
|
||||||
|
#### Docker Sandboxes (Experimentell)
|
||||||
|
|
||||||
|
Agents in isolierten Sandboxes mit Hypervisor-Isolation ausführen. Standardmäßig wird Claude Code gestartet. Erfordert die [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/).
|
||||||
|
|
||||||
|
```
|
||||||
|
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
|
||||||
|
```
|
||||||
|
|
||||||
|
Unterstützt Claude Code, Codex und Gemini CLI. Weitere Details in der [Sandbox-Dokumentation](docker/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
11
README.ja.md
11
README.ja.md
@@ -72,6 +72,8 @@
|
|||||||
|
|
||||||
### セルフホスト(オープンソース)
|
### セルフホスト(オープンソース)
|
||||||
|
|
||||||
|
#### npm
|
||||||
|
|
||||||
**npx** で今すぐ CloudCLI UI を試せます(**Node.js** v22+ が必要):
|
**npx** で今すぐ CloudCLI UI を試せます(**Node.js** v22+ が必要):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -89,6 +91,15 @@ cloudcli
|
|||||||
|
|
||||||
より詳細な設定オプション、PM2、リモートサーバー設定などについては **[ドキュメントはこちら →](https://cloudcli.ai/docs)** を参照してください。
|
より詳細な設定オプション、PM2、リモートサーバー設定などについては **[ドキュメントはこちら →](https://cloudcli.ai/docs)** を参照してください。
|
||||||
|
|
||||||
|
#### Docker Sandboxes(実験的)
|
||||||
|
|
||||||
|
ハイパーバイザーレベルの分離でエージェントをサンドボックスで実行します。デフォルトでは Claude Code が起動します。[`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/) が必要です。
|
||||||
|
|
||||||
|
```
|
||||||
|
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude Code、Codex、Gemini CLI に対応。詳細は[サンドボックスのドキュメント](docker/)をご覧ください。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
14
README.ko.md
14
README.ko.md
@@ -72,6 +72,8 @@
|
|||||||
|
|
||||||
### 셀프 호스트 (오픈 소스)
|
### 셀프 호스트 (오픈 소스)
|
||||||
|
|
||||||
|
#### npm
|
||||||
|
|
||||||
**npx**로 즉시 CloudCLI UI를 실행하세요 (Node.js v22+ 필요):
|
**npx**로 즉시 CloudCLI UI를 실행하세요 (Node.js v22+ 필요):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -87,7 +89,17 @@ cloudcli
|
|||||||
|
|
||||||
`http://localhost:3001`을 열면 기존 세션이 자동으로 발견됩니다.
|
`http://localhost:3001`을 열면 기존 세션이 자동으로 발견됩니다.
|
||||||
|
|
||||||
자세한 구성 옵션, PM2, 원격 서버 설정 등은 **[문서 →](https://cloudcli.ai/docs)**를 참고하세요
|
자세한 구성 옵션, PM2, 원격 서버 설정 등은 **[문서 →](https://cloudcli.ai/docs)**를 참고하세요.
|
||||||
|
|
||||||
|
#### Docker Sandboxes (실험적)
|
||||||
|
|
||||||
|
하이퍼바이저 수준 격리로 에이전트를 샌드박스에서 실행합니다. 기본 에이전트는 Claude Code입니다. [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)가 필요합니다.
|
||||||
|
|
||||||
|
```
|
||||||
|
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
|
||||||
|
```
|
||||||
|
|
||||||
|
Claude Code, Codex, Gemini CLI를 지원합니다. 자세한 내용은 [샌드박스 문서](docker/)를 참고하세요.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
48
README.md
48
README.md
@@ -76,6 +76,8 @@ The fastest way to get started — no local setup required. Get a fully managed,
|
|||||||
|
|
||||||
### Self-Hosted (Open source)
|
### Self-Hosted (Open source)
|
||||||
|
|
||||||
|
#### npm
|
||||||
|
|
||||||
Try CloudCLI UI instantly with **npx** (requires **Node.js** v22+):
|
Try CloudCLI UI instantly with **npx** (requires **Node.js** v22+):
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -91,33 +93,41 @@ cloudcli
|
|||||||
|
|
||||||
Open `http://localhost:3001` — all your existing sessions are discovered automatically.
|
Open `http://localhost:3001` — all your existing sessions are discovered automatically.
|
||||||
|
|
||||||
Visit the **[documentation →](https://cloudcli.ai/docs)** for more full configuration options, PM2, remote server setup and more
|
Visit the **[documentation →](https://cloudcli.ai/docs)** for full configuration options, PM2, remote server setup and more.
|
||||||
|
|
||||||
|
#### Docker Sandboxes (Experimental)
|
||||||
|
|
||||||
|
Run agents in isolated sandboxes with hypervisor-level isolation. Starts Claude Code by default. Requires the [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/).
|
||||||
|
|
||||||
|
```
|
||||||
|
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
|
||||||
|
```
|
||||||
|
|
||||||
|
Supports Claude Code, Codex, and Gemini CLI. See the [sandbox docs](docker/) for setup and advanced options.
|
||||||
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Which option is right for you?
|
## Which option is right for you?
|
||||||
|
|
||||||
CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self-host it on your own machine, or use CloudCLI Cloud which builds on top of it with a full managed cloud environment, team features, and deeper integrations.
|
CloudCLI UI is the open source UI layer that powers CloudCLI Cloud. You can self-host it on your own machine, run it in a Docker sandbox for isolation, or use CloudCLI Cloud for a fully managed environment.
|
||||||
|
|
||||||
| | CloudCLI UI (Self-hosted) | CloudCLI Cloud |
|
| | Self-Hosted (npm) | Self-Hosted (Docker Sandbox) *(Experimental)* | CloudCLI Cloud |
|
||||||
|---|---|---|
|
|---|---|---|---|
|
||||||
| **Best for** | Developers who want a full UI for local agent sessions on their own machine | Teams and developers who want agents running in the cloud, accessible from anywhere |
|
| **Best for** | Local agent sessions on your own machine | Isolated agents with web/mobile IDE | Teams who want agents in the cloud |
|
||||||
| **How you access it** | Browser via `[yourip]:port` | Browser, any IDE, REST API, n8n |
|
| **How you access it** | Browser via `[yourip]:port` | Browser via `localhost:port` | Browser, any IDE, REST API, n8n |
|
||||||
| **Setup** | `npx @cloudcli-ai/cloudcli` | No setup required |
|
| **Setup** | `npx @cloudcli-ai/cloudcli` | `npx @cloudcli-ai/cloudcli@latest sandbox ~/project` | No setup required |
|
||||||
| **Machine needs to stay on** | Yes | No |
|
| **Isolation** | Runs on your host | Hypervisor-level sandbox (microVM) | Full cloud isolation |
|
||||||
| **Mobile access** | Any browser on your network | Any device, native app coming |
|
| **Machine needs to stay on** | Yes | Yes | No |
|
||||||
| **Sessions available** | All sessions auto-discovered from `~/.claude` | All sessions within your cloud environment |
|
| **Mobile access** | Any browser on your network | Any browser on your network | Any device, native app coming |
|
||||||
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
|
| **Agents supported** | Claude Code, Cursor CLI, Codex, Gemini CLI | Claude Code, Codex, Gemini CLI | Claude Code, Cursor CLI, Codex, Gemini CLI |
|
||||||
| **File explorer and Git** | Yes, built into the UI | Yes, built into the UI |
|
| **File explorer and Git** | Yes | Yes | Yes |
|
||||||
| **MCP configuration** | Managed via UI, synced with your local `~/.claude` config | Managed via UI |
|
| **MCP configuration** | Synced with `~/.claude` | Managed via UI | Managed via UI |
|
||||||
| **IDE access** | Your local IDE | Any IDE connected to your cloud environment |
|
| **REST API** | Yes | Yes | Yes |
|
||||||
| **REST API** | Yes | Yes |
|
| **Team sharing** | No | No | Yes |
|
||||||
| **n8n node** | No | Yes |
|
| **Platform cost** | Free, open source | Free, open source | Starts at $7/month |
|
||||||
| **Team sharing** | No | Yes |
|
|
||||||
| **Platform cost** | Free, open source | Starts at $7/month |
|
|
||||||
|
|
||||||
> Both options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI.
|
> All options use your own AI subscriptions (Claude, Cursor, etc.) — CloudCLI provides the environment, not the AI.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
13
README.ru.md
13
README.ru.md
@@ -76,6 +76,8 @@
|
|||||||
|
|
||||||
### Self-Hosted (Open source)
|
### Self-Hosted (Open source)
|
||||||
|
|
||||||
|
#### npm
|
||||||
|
|
||||||
Попробовать CloudCLI UI можно сразу через **npx** (требуется **Node.js** v22+):
|
Попробовать CloudCLI UI можно сразу через **npx** (требуется **Node.js** v22+):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -91,8 +93,17 @@ cloudcli
|
|||||||
|
|
||||||
Откройте `http://localhost:3001` — все ваши существующие сессии будут обнаружены автоматически.
|
Откройте `http://localhost:3001` — все ваши существующие сессии будут обнаружены автоматически.
|
||||||
|
|
||||||
Посетите **[документацию →](https://cloudcli.ai/docs)**, чтобы узнать про дополнительные варианты конфигурации, PM2, настройку удалённого сервера и многое другое
|
Посетите **[документацию →](https://cloudcli.ai/docs)**, чтобы узнать про дополнительные варианты конфигурации, PM2, настройку удалённого сервера и многое другое.
|
||||||
|
|
||||||
|
#### Docker Sandboxes (Экспериментально)
|
||||||
|
|
||||||
|
Запускайте агентов в изолированных песочницах с гипервизорной изоляцией. По умолчанию запускается Claude Code. Требуется [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/).
|
||||||
|
|
||||||
|
```
|
||||||
|
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
|
||||||
|
```
|
||||||
|
|
||||||
|
Поддерживаются Claude Code, Codex и Gemini CLI. Подробнее в [документации sandbox](docker/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -72,6 +72,8 @@
|
|||||||
|
|
||||||
### 自托管(开源)
|
### 自托管(开源)
|
||||||
|
|
||||||
|
#### npm
|
||||||
|
|
||||||
启动 CloudCLI UI,只需一行 `npx`(需要 Node.js v22+):
|
启动 CloudCLI UI,只需一行 `npx`(需要 Node.js v22+):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -87,7 +89,17 @@ cloudcli
|
|||||||
|
|
||||||
打开 `http://localhost:3001`,系统会自动发现所有现有会话。
|
打开 `http://localhost:3001`,系统会自动发现所有现有会话。
|
||||||
|
|
||||||
更多配置选项、PM2、远程服务器设置等,请参阅 **[文档 →](https://cloudcli.ai/docs)**
|
更多配置选项、PM2、远程服务器设置等,请参阅 **[文档 →](https://cloudcli.ai/docs)**。
|
||||||
|
|
||||||
|
#### Docker Sandboxes(实验性)
|
||||||
|
|
||||||
|
在隔离的沙箱中运行代理,具有虚拟机管理程序级别的隔离。默认启动 Claude Code。需要 [`sbx` CLI](https://docs.docker.com/ai/sandboxes/get-started/)。
|
||||||
|
|
||||||
|
```
|
||||||
|
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
|
||||||
|
```
|
||||||
|
|
||||||
|
支持 Claude Code、Codex 和 Gemini CLI。详情请参阅 [沙箱文档](docker/)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
187
docker/README.md
187
docker/README.md
@@ -1,89 +1,160 @@
|
|||||||
# CloudCLI — Docker Sandbox Templates
|
<!-- Docker Hub short description (100 chars max): -->
|
||||||
|
<!-- Sandbox templates for running AI coding agents with a web & mobile IDE (Claude Code, Codex, Gemini) -->
|
||||||
|
|
||||||
Run AI coding agents with a full web IDE inside [Docker Sandboxes](https://docs.docker.com/ai/sandboxes/).
|
# Sandboxed coding agents with a web & mobile IDE (CloudCLI)
|
||||||
|
|
||||||
Instead of a terminal-only experience, get a browser-based interface with chat, file explorer, git panel, shell, and MCP configuration — all running safely inside an isolated sandbox.
|
[Docker Sandbox](https://docs.docker.com/ai/sandboxes/) templates that add [CloudCLI](https://cloudcli.ai) on top of Claude Code, Codex, and Gemini CLI. You get a full web and mobile IDE accessible from any browser on any device.
|
||||||
|
|
||||||
## Available Templates
|
## Get started
|
||||||
|
|
||||||
| Template | Base Image | Agent |
|
### 1. Install the sbx CLI
|
||||||
|----------|-----------|-------|
|
|
||||||
| `cloudcli-ai/sandbox:claude-code` | `docker/sandbox-templates:claude-code` | Claude Code |
|
|
||||||
| `cloudcli-ai/sandbox:codex` | `docker/sandbox-templates:codex` | OpenAI Codex |
|
|
||||||
| `cloudcli-ai/sandbox:gemini` | `docker/sandbox-templates:gemini` | Gemini CLI |
|
|
||||||
|
|
||||||
## Quick Start
|
Docker Sandboxes run agents in isolated microVMs. Install the `sbx` CLI:
|
||||||
|
|
||||||
### 1. Start a sandbox with the template
|
- **macOS**: `brew install docker/tap/sbx`
|
||||||
|
- **Windows**: `winget install -h Docker.sbx`
|
||||||
|
- **Linux**: `sudo apt-get install docker-sbx`
|
||||||
|
|
||||||
|
Full instructions: [docs.docker.com/ai/sandboxes/get-started](https://docs.docker.com/ai/sandboxes/get-started/)
|
||||||
|
|
||||||
|
### 2. Store your API key
|
||||||
|
|
||||||
|
`sbx` manages credentials securely — your API key never enters the sandbox. Store it once:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sbx run --template docker.io/cloudcli-ai/sandbox:claude-code claude ~/my-project
|
sbx login
|
||||||
|
sbx secret set -g anthropic
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Forward the UI port
|
### 3. Launch Claude Code
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sbx ports <sandbox-name> --publish 3001:3001
|
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Open the browser
|
Open **http://localhost:3001**. Set a password on first visit. Start building.
|
||||||
|
|
||||||
```
|
### Using a different agent
|
||||||
http://localhost:3001
|
|
||||||
|
Store the matching API key and pass `--agent`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# OpenAI Codex
|
||||||
|
sbx secret set -g openai
|
||||||
|
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project --agent codex
|
||||||
|
|
||||||
|
# Gemini CLI
|
||||||
|
sbx secret set -g google
|
||||||
|
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project --agent gemini
|
||||||
```
|
```
|
||||||
|
|
||||||
On first visit you'll set a password — this protects the UI if the port is ever exposed beyond localhost.
|
### Available templates
|
||||||
|
|
||||||
## What You Get
|
| Agent | Template |
|
||||||
|
|-------|----------|
|
||||||
|
| **Claude Code** (default) | `docker.io/cloudcliai/sandbox:claude-code` |
|
||||||
|
| OpenAI Codex | `docker.io/cloudcliai/sandbox:codex` |
|
||||||
|
| Gemini CLI | `docker.io/cloudcliai/sandbox:gemini` |
|
||||||
|
|
||||||
- **Chat** — Rich conversation UI with markdown rendering, code blocks, and message history
|
These are used with `--template` when running `sbx` directly (see [Advanced usage](#advanced-usage)).
|
||||||
- **Files** — Visual file tree with syntax-highlighted editor
|
|
||||||
- **Git** — Diff viewer, staging, branch switching, and commit — all visual
|
## Managing sandboxes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sbx ls # List all sandboxes
|
||||||
|
sbx stop my-project # Stop (preserves state)
|
||||||
|
sbx start my-project # Restart a stopped sandbox
|
||||||
|
sbx rm my-project # Remove everything
|
||||||
|
sbx exec my-project bash # Open a shell inside the sandbox
|
||||||
|
```
|
||||||
|
|
||||||
|
If you install CloudCLI globally (`npm install -g @cloudcli-ai/cloudcli`), you can also use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cloudcli sandbox ls
|
||||||
|
cloudcli sandbox start my-project # Restart and re-launch web UI
|
||||||
|
cloudcli sandbox logs my-project # View server logs
|
||||||
|
```
|
||||||
|
|
||||||
|
## What you get
|
||||||
|
|
||||||
|
- **Chat** — Markdown rendering, code blocks, message history
|
||||||
|
- **Files** — File tree with syntax-highlighted editor
|
||||||
|
- **Git** — Diff viewer, staging, branch switching, commits
|
||||||
- **Shell** — Built-in terminal emulator
|
- **Shell** — Built-in terminal emulator
|
||||||
- **MCP** — Configure Model Context Protocol servers through the UI
|
- **MCP** — Configure Model Context Protocol servers visually
|
||||||
- **Mobile** — Works on tablet and phone browsers
|
- **Mobile** — Works on tablet and phone browsers
|
||||||
|
|
||||||
## Building Locally
|
Your project directory is mounted bidirectionally — edits propagate in real time, both ways.
|
||||||
|
|
||||||
All Dockerfiles share scripts from `shared/`. Build with the `docker/` directory as context:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Claude Code variant
|
|
||||||
docker build -f docker/claude-code/Dockerfile -t cloudcli-sandbox:claude-code docker/
|
|
||||||
|
|
||||||
# Codex variant
|
|
||||||
docker build -f docker/codex/Dockerfile -t cloudcli-sandbox:codex docker/
|
|
||||||
|
|
||||||
# Gemini variant
|
|
||||||
docker build -f docker/gemini/Dockerfile -t cloudcli-sandbox:gemini docker/
|
|
||||||
```
|
|
||||||
|
|
||||||
## How It Works
|
|
||||||
|
|
||||||
Each template extends Docker's official sandbox base image and adds:
|
|
||||||
|
|
||||||
1. **Node.js 22** — Runtime for CloudCLI
|
|
||||||
2. **CloudCLI** — Installed globally via `npm install -g @cloudcli-ai/cloudcli`
|
|
||||||
3. **Auto-start** — The UI server starts in the background when the sandbox shell opens (port 3001)
|
|
||||||
|
|
||||||
The agent (Claude Code, Codex, or Gemini) comes from the base image. CloudCLI connects to it and provides the web interface on top.
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
| Environment Variable | Default | Description |
|
Set variables at creation time with `--env`:
|
||||||
|---------------------|---------|-------------|
|
|
||||||
| `SERVER_PORT` | `3001` | Port for the web UI |
|
|
||||||
| `HOST` | `0.0.0.0` | Bind address |
|
|
||||||
| `DATABASE_PATH` | `~/.cloudcli/auth.db` | SQLite database location |
|
|
||||||
|
|
||||||
## Network Policies
|
|
||||||
|
|
||||||
If your sandbox uses restricted network policies, allow the UI port:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
sbx policy allow network "localhost:3001"
|
npx @cloudcli-ai/cloudcli@latest sandbox ~/my-project --env SERVER_PORT=8080
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Or inside a running sandbox:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sbx exec my-project bash -c 'echo "export SERVER_PORT=8080" >> /etc/sandbox-persistent.sh'
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart CloudCLI for changes to take effect:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sbx exec my-project bash -c 'pkill -f "server/index.js"'
|
||||||
|
sbx exec -d my-project cloudcli start --port 3001
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `SERVER_PORT` | `3001` | Web UI port |
|
||||||
|
| `HOST` | `0.0.0.0` | Bind address (must be `0.0.0.0` for `sbx ports`) |
|
||||||
|
| `DATABASE_PATH` | `~/.cloudcli/auth.db` | SQLite database location |
|
||||||
|
|
||||||
|
## Advanced usage
|
||||||
|
|
||||||
|
For branch mode, multiple workspaces, memory limits, or the terminal agent experience, use `sbx` with the template:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Terminal agent + web UI
|
||||||
|
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project --name my-project
|
||||||
|
sbx ports my-project --publish 3001:3001
|
||||||
|
|
||||||
|
# Branch mode (Git worktree isolation)
|
||||||
|
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project --branch my-feature
|
||||||
|
|
||||||
|
# Multiple workspaces
|
||||||
|
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/project ~/shared-libs:ro
|
||||||
|
|
||||||
|
# Pass a prompt directly
|
||||||
|
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project -- "Fix the auth bug"
|
||||||
|
```
|
||||||
|
|
||||||
|
CloudCLI auto-starts via `.bashrc` when using `sbx run`.
|
||||||
|
|
||||||
|
Full options in the [Docker Sandboxes usage guide](https://docs.docker.com/ai/sandboxes/usage/).
|
||||||
|
|
||||||
|
## Network policies
|
||||||
|
|
||||||
|
Sandboxes restrict outbound access by default. To reach host services from inside the sandbox:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sbx policy allow network localhost:11434
|
||||||
|
# Inside the sandbox: curl http://host.docker.internal:11434
|
||||||
|
```
|
||||||
|
|
||||||
|
The web UI itself doesn't need a policy — access it via `sbx ports`.
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
- [CloudCLI Cloud](https://cloudcli.ai) — fully managed, no setup required
|
||||||
|
- [Documentation](https://cloudcli.ai/docs) — full configuration guide
|
||||||
|
- [Discord](https://discord.gg/buxwujPNRE) — community support
|
||||||
|
- [GitHub](https://github.com/siteboon/claudecodeui) — source code and issues
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
These templates are free and open-source under the same license as CloudCLI (AGPL-3.0-or-later).
|
AGPL-3.0-or-later
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
|
|||||||
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
|
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
|
||||||
|
|
||||||
USER agent
|
USER agent
|
||||||
RUN npm install -g @cloudcli-ai/cloudcli
|
RUN npm install -g @cloudcli-ai/cloudcli && cloudcli --version
|
||||||
|
|
||||||
COPY shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
|
COPY --chown=agent:agent shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
|
||||||
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc
|
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
|
|||||||
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
|
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
|
||||||
|
|
||||||
USER agent
|
USER agent
|
||||||
RUN npm install -g @cloudcli-ai/cloudcli
|
RUN npm install -g @cloudcli-ai/cloudcli && cloudcli --version
|
||||||
|
|
||||||
COPY shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
|
COPY --chown=agent:agent shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
|
||||||
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc
|
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ COPY shared/install-cloudcli.sh /tmp/install-cloudcli.sh
|
|||||||
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
|
RUN chmod +x /tmp/install-cloudcli.sh && /tmp/install-cloudcli.sh
|
||||||
|
|
||||||
USER agent
|
USER agent
|
||||||
RUN npm install -g @cloudcli-ai/cloudcli
|
RUN npm install -g @cloudcli-ai/cloudcli && cloudcli --version
|
||||||
|
|
||||||
COPY shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
|
COPY --chown=agent:agent shared/start-cloudcli.sh /home/agent/.cloudcli-start.sh
|
||||||
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc
|
RUN echo '. ~/.cloudcli-start.sh' >> /home/agent/.bashrc
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
# Install Node.js 22 LTS
|
# Install build tools needed for native modules (node-pty, better-sqlite3, bcrypt)
|
||||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
# Node.js is already provided by the sandbox base image
|
||||||
|
apt-get update && apt-get install -y --no-install-recommends \
|
||||||
# Install Node.js + build tools needed for native modules (node-pty, better-sqlite3, bcrypt)
|
build-essential python3 python3-setuptools \
|
||||||
# Node.js + build tools for native modules + common dev tools
|
|
||||||
apt-get install -y --no-install-recommends \
|
|
||||||
nodejs build-essential python3 python3-setuptools \
|
|
||||||
jq ripgrep sqlite3 zip unzip tree vim-tiny
|
jq ripgrep sqlite3 zip unzip tree vim-tiny
|
||||||
|
|
||||||
# Clean up apt cache to reduce image size
|
# Clean up apt cache to reduce image size
|
||||||
|
|||||||
@@ -4,19 +4,14 @@
|
|||||||
# This script is sourced from ~/.bashrc on sandbox shell open.
|
# This script is sourced from ~/.bashrc on sandbox shell open.
|
||||||
|
|
||||||
if ! pgrep -f "server/index.js" > /dev/null 2>&1; then
|
if ! pgrep -f "server/index.js" > /dev/null 2>&1; then
|
||||||
# Start the pre-installed version immediately
|
|
||||||
nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 &
|
nohup cloudcli start --port 3001 > /tmp/cloudcli-ui.log 2>&1 &
|
||||||
disown
|
disown
|
||||||
|
|
||||||
# Check for updates in the background (non-blocking)
|
|
||||||
nohup npm update -g @cloudcli-ai/cloudcli > /tmp/cloudcli-update.log 2>&1 &
|
|
||||||
disown
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " CloudCLI is starting on port 3001..."
|
echo " CloudCLI is starting on port 3001..."
|
||||||
echo ""
|
echo ""
|
||||||
echo " To access the web UI, forward the port:"
|
echo " Forward the port from another terminal:"
|
||||||
echo " sbx ports \$(hostname) --publish 3001:3001"
|
echo " sbx ports <sandbox-name> --publish 3001:3001"
|
||||||
echo ""
|
echo ""
|
||||||
echo " Then open: http://localhost:3001"
|
echo " Then open: http://localhost:3001"
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.28.1",
|
"version": "1.29.2",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.28.1",
|
"version": "1.29.2",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@cloudcli-ai/cloudcli",
|
"name": "@cloudcli-ai/cloudcli",
|
||||||
"version": "1.28.1",
|
"version": "1.29.2",
|
||||||
"description": "A web-based UI for Claude Code CLI",
|
"description": "A web-based UI for Claude Code CLI",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "server/index.js",
|
"main": "server/index.js",
|
||||||
@@ -35,7 +35,8 @@
|
|||||||
"release": "./release.sh",
|
"release": "./release.sh",
|
||||||
"prepublishOnly": "npm run build",
|
"prepublishOnly": "npm run build",
|
||||||
"postinstall": "node scripts/fix-node-pty.js",
|
"postinstall": "node scripts/fix-node-pty.js",
|
||||||
"prepare": "husky"
|
"prepare": "husky",
|
||||||
|
"update:platform": "./update-platform.sh"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"claude code",
|
"claude code",
|
||||||
|
|||||||
361
server/cli.js
361
server/cli.js
@@ -7,6 +7,7 @@
|
|||||||
* Commands:
|
* Commands:
|
||||||
* (no args) - Start the server (default)
|
* (no args) - Start the server (default)
|
||||||
* start - Start the server
|
* start - Start the server
|
||||||
|
* sandbox - Manage Docker sandbox environments
|
||||||
* status - Show configuration and data locations
|
* status - Show configuration and data locations
|
||||||
* help - Show help information
|
* help - Show help information
|
||||||
* version - Show version information
|
* version - Show version information
|
||||||
@@ -150,6 +151,7 @@ Usage:
|
|||||||
|
|
||||||
Commands:
|
Commands:
|
||||||
start Start the CloudCLI server (default)
|
start Start the CloudCLI server (default)
|
||||||
|
sandbox Manage Docker sandbox environments
|
||||||
status Show configuration and data locations
|
status Show configuration and data locations
|
||||||
update Update to the latest version
|
update Update to the latest version
|
||||||
help Show this help information
|
help Show this help information
|
||||||
@@ -164,8 +166,7 @@ Options:
|
|||||||
Examples:
|
Examples:
|
||||||
$ cloudcli # Start with defaults
|
$ cloudcli # Start with defaults
|
||||||
$ cloudcli --port 8080 # Start on port 8080
|
$ cloudcli --port 8080 # Start on port 8080
|
||||||
$ cloudcli -p 3000 # Short form for port
|
$ cloudcli sandbox ~/my-project # Run in a Docker sandbox
|
||||||
$ cloudcli start --port 4000 # Explicit start command
|
|
||||||
$ cloudcli status # Show configuration
|
$ cloudcli status # Show configuration
|
||||||
|
|
||||||
Environment Variables:
|
Environment Variables:
|
||||||
@@ -244,6 +245,353 @@ async function updatePackage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Sandbox command ─────────────────────────────────────────
|
||||||
|
|
||||||
|
const SANDBOX_TEMPLATES = {
|
||||||
|
claude: 'docker.io/cloudcliai/sandbox:claude-code',
|
||||||
|
codex: 'docker.io/cloudcliai/sandbox:codex',
|
||||||
|
gemini: 'docker.io/cloudcliai/sandbox:gemini',
|
||||||
|
};
|
||||||
|
|
||||||
|
const SANDBOX_SECRETS = {
|
||||||
|
claude: 'anthropic',
|
||||||
|
codex: 'openai',
|
||||||
|
gemini: 'google',
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseSandboxArgs(args) {
|
||||||
|
const result = {
|
||||||
|
subcommand: null,
|
||||||
|
workspace: null,
|
||||||
|
agent: 'claude',
|
||||||
|
name: null,
|
||||||
|
port: 3001,
|
||||||
|
template: null,
|
||||||
|
env: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const subcommands = ['ls', 'stop', 'start', 'rm', 'logs', 'help'];
|
||||||
|
|
||||||
|
for (let i = 0; i < args.length; i++) {
|
||||||
|
const arg = args[i];
|
||||||
|
|
||||||
|
if (i === 0 && subcommands.includes(arg)) {
|
||||||
|
result.subcommand = arg;
|
||||||
|
} else if (arg === '--agent' || arg === '-a') {
|
||||||
|
result.agent = args[++i];
|
||||||
|
} else if (arg === '--name' || arg === '-n') {
|
||||||
|
result.name = args[++i];
|
||||||
|
} else if (arg === '--port') {
|
||||||
|
result.port = parseInt(args[++i], 10);
|
||||||
|
} else if (arg === '--template' || arg === '-t') {
|
||||||
|
result.template = args[++i];
|
||||||
|
} else if (arg === '--env' || arg === '-e') {
|
||||||
|
result.env.push(args[++i]);
|
||||||
|
} else if (!arg.startsWith('-')) {
|
||||||
|
if (!result.subcommand) {
|
||||||
|
result.workspace = arg;
|
||||||
|
} else {
|
||||||
|
result.name = arg; // for stop/start/rm/logs <name>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default subcommand based on what we got
|
||||||
|
if (!result.subcommand) {
|
||||||
|
result.subcommand = 'create';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive name from workspace path if not set
|
||||||
|
if (!result.name && result.workspace) {
|
||||||
|
result.name = path.basename(path.resolve(result.workspace.replace(/^~/, os.homedir())));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default template from agent
|
||||||
|
if (!result.template) {
|
||||||
|
result.template = SANDBOX_TEMPLATES[result.agent] || SANDBOX_TEMPLATES.claude;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSandboxHelp() {
|
||||||
|
console.log(`
|
||||||
|
${c.bright('CloudCLI Sandbox')} — Run CloudCLI inside Docker Sandboxes
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cloudcli sandbox <workspace> Create and start a sandbox
|
||||||
|
cloudcli sandbox <subcommand> [name] Manage sandboxes
|
||||||
|
|
||||||
|
Subcommands:
|
||||||
|
${c.bright('(default)')} Create a sandbox and start the web UI
|
||||||
|
${c.bright('ls')} List all sandboxes
|
||||||
|
${c.bright('start')} Restart a stopped sandbox and re-launch the web UI
|
||||||
|
${c.bright('stop')} Stop a sandbox (preserves state)
|
||||||
|
${c.bright('rm')} Remove a sandbox
|
||||||
|
${c.bright('logs')} Show CloudCLI server logs
|
||||||
|
${c.bright('help')} Show this help
|
||||||
|
|
||||||
|
Options:
|
||||||
|
-a, --agent <agent> Agent to use: claude, codex, gemini (default: claude)
|
||||||
|
-n, --name <name> Sandbox name (default: derived from workspace folder)
|
||||||
|
-t, --template <image> Custom template image
|
||||||
|
-e, --env <KEY=VALUE> Set environment variable (repeatable)
|
||||||
|
--port <port> Host port for the web UI (default: 3001)
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
$ cloudcli sandbox ~/my-project
|
||||||
|
$ cloudcli sandbox ~/my-project --agent codex --port 8080
|
||||||
|
$ cloudcli sandbox ~/my-project --env SERVER_PORT=8080 --env HOST=0.0.0.0
|
||||||
|
$ cloudcli sandbox ls
|
||||||
|
$ cloudcli sandbox stop my-project
|
||||||
|
$ cloudcli sandbox start my-project
|
||||||
|
$ cloudcli sandbox rm my-project
|
||||||
|
|
||||||
|
Prerequisites:
|
||||||
|
1. Install sbx CLI: https://docs.docker.com/ai/sandboxes/get-started/
|
||||||
|
2. Authenticate and store your API key:
|
||||||
|
sbx login
|
||||||
|
sbx secret set -g anthropic # for Claude
|
||||||
|
sbx secret set -g openai # for Codex
|
||||||
|
sbx secret set -g google # for Gemini
|
||||||
|
|
||||||
|
Advanced usage:
|
||||||
|
For branch mode, multiple workspaces, memory limits, network policies,
|
||||||
|
or passing prompts to the agent, use sbx directly with the template:
|
||||||
|
|
||||||
|
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/my-project --branch my-feature
|
||||||
|
sbx run --template docker.io/cloudcliai/sandbox:claude-code claude ~/project ~/libs:ro --memory 8g
|
||||||
|
|
||||||
|
Full Docker Sandboxes docs: https://docs.docker.com/ai/sandboxes/usage/
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sandboxCommand(args) {
|
||||||
|
const { execFileSync, spawn: spawnProcess } = await import('child_process');
|
||||||
|
|
||||||
|
// Safe execution — uses execFileSync (no shell) to prevent injection
|
||||||
|
const sbx = (subcmd, opts = {}) => {
|
||||||
|
const result = execFileSync('sbx', subcmd, {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: opts.inherit ? 'inherit' : 'pipe',
|
||||||
|
});
|
||||||
|
return result || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const opts = parseSandboxArgs(args);
|
||||||
|
|
||||||
|
if (opts.subcommand === 'help') {
|
||||||
|
showSandboxHelp();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate name (alphanumeric, hyphens, underscores only)
|
||||||
|
if (opts.name && !/^[\w-]+$/.test(opts.name)) {
|
||||||
|
console.error(`\n${c.error('❌')} Invalid sandbox name: ${opts.name}`);
|
||||||
|
console.log(` Names may only contain letters, numbers, hyphens, and underscores.\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check sbx is installed
|
||||||
|
try {
|
||||||
|
sbx(['version']);
|
||||||
|
} catch {
|
||||||
|
console.error(`\n${c.error('❌')} ${c.bright('sbx')} CLI not found.\n`);
|
||||||
|
console.log(` Install it from: ${c.info('https://docs.docker.com/ai/sandboxes/get-started/')}`);
|
||||||
|
console.log(` Then run: ${c.bright('sbx login')}`);
|
||||||
|
console.log(` And store your API key: ${c.bright('sbx secret set -g anthropic')}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (opts.subcommand) {
|
||||||
|
|
||||||
|
case 'ls':
|
||||||
|
sbx(['ls'], { inherit: true });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'stop':
|
||||||
|
if (!opts.name) {
|
||||||
|
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox stop <name>\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
sbx(['stop', opts.name], { inherit: true });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'rm':
|
||||||
|
if (!opts.name) {
|
||||||
|
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox rm <name>\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
sbx(['rm', opts.name], { inherit: true });
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'logs':
|
||||||
|
if (!opts.name) {
|
||||||
|
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox logs <name>\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
sbx(['exec', opts.name, 'bash', '-c', 'cat /tmp/cloudcli-ui.log'], { inherit: true });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`\n${c.error('❌')} Could not read logs: ${e.message || 'Is the sandbox running?'}\n`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'start': {
|
||||||
|
if (!opts.name) {
|
||||||
|
console.error(`\n${c.error('❌')} Sandbox name required: cloudcli sandbox start <name>\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(`\n${c.info('▶')} Starting sandbox ${c.bright(opts.name)}...`);
|
||||||
|
const restartRun = spawnProcess('sbx', ['run', opts.name], {
|
||||||
|
detached: true,
|
||||||
|
stdio: ['ignore', 'ignore', 'ignore'],
|
||||||
|
});
|
||||||
|
restartRun.unref();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
|
|
||||||
|
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
||||||
|
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
|
||||||
|
|
||||||
|
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
||||||
|
try {
|
||||||
|
sbx(['ports', opts.name, '--publish', `${opts.port}:3001`]);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e.stdout || e.stderr || e.message || '';
|
||||||
|
if (msg.includes('address already in use')) {
|
||||||
|
const altPort = opts.port + 1;
|
||||||
|
console.log(`${c.warn('⚠')} Port ${opts.port} in use, trying ${altPort}...`);
|
||||||
|
try {
|
||||||
|
sbx(['ports', opts.name, '--publish', `${altPort}:3001`]);
|
||||||
|
opts.port = altPort;
|
||||||
|
} catch {
|
||||||
|
console.error(`${c.error('❌')} Ports ${opts.port} and ${altPort} both in use. Use --port to specify a free port.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\n${c.ok('✔')} ${c.bright('CloudCLI is ready!')}`);
|
||||||
|
console.log(` ${c.info('→')} ${c.bright(`http://localhost:${opts.port}`)}\n`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'create': {
|
||||||
|
if (!opts.workspace) {
|
||||||
|
console.error(`\n${c.error('❌')} Workspace path required: cloudcli sandbox <path>\n`);
|
||||||
|
console.log(` Example: ${c.bright('cloudcli sandbox ~/my-project')}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace = opts.workspace.startsWith('~')
|
||||||
|
? opts.workspace.replace(/^~/, os.homedir())
|
||||||
|
: path.resolve(opts.workspace);
|
||||||
|
|
||||||
|
if (!fs.existsSync(workspace)) {
|
||||||
|
console.error(`\n${c.error('❌')} Workspace path not found: ${c.dim(workspace)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = SANDBOX_SECRETS[opts.agent] || 'anthropic';
|
||||||
|
|
||||||
|
// Check if the required secret is stored
|
||||||
|
try {
|
||||||
|
const secretList = sbx(['secret', 'ls']);
|
||||||
|
if (!secretList.includes(secret)) {
|
||||||
|
console.error(`\n${c.error('❌')} No ${c.bright(secret)} API key found.\n`);
|
||||||
|
console.log(` Run: ${c.bright(`sbx secret set -g ${secret}`)}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} catch { /* sbx secret ls not available, skip check */ }
|
||||||
|
|
||||||
|
console.log(`\n${c.bright('CloudCLI Sandbox')}`);
|
||||||
|
console.log(c.dim('─'.repeat(50)));
|
||||||
|
console.log(` Agent: ${c.info(opts.agent)} ${c.dim(`(${secret} credentials)`)}`);
|
||||||
|
console.log(` Workspace: ${c.dim(workspace)}`);
|
||||||
|
console.log(` Name: ${c.dim(opts.name)}`);
|
||||||
|
console.log(` Template: ${c.dim(opts.template)}`);
|
||||||
|
console.log(` Port: ${c.dim(String(opts.port))}`);
|
||||||
|
if (opts.env.length > 0) {
|
||||||
|
console.log(` Env: ${c.dim(opts.env.join(', '))}`);
|
||||||
|
}
|
||||||
|
console.log(c.dim('─'.repeat(50)));
|
||||||
|
|
||||||
|
// Step 1: Launch sandbox with sbx run in background.
|
||||||
|
// sbx run creates the sandbox (or reconnects) AND holds an active session,
|
||||||
|
// which prevents the sandbox from auto-stopping.
|
||||||
|
console.log(`\n${c.info('▶')} Creating sandbox ${c.bright(opts.name)}...`);
|
||||||
|
const bgRun = spawnProcess('sbx', [
|
||||||
|
'run', '--template', opts.template, '--name', opts.name, opts.agent, workspace,
|
||||||
|
], {
|
||||||
|
detached: true,
|
||||||
|
stdio: ['ignore', 'ignore', 'ignore'],
|
||||||
|
});
|
||||||
|
bgRun.unref();
|
||||||
|
// Wait for sandbox to be ready
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||||
|
|
||||||
|
// Step 2: Inject environment variables
|
||||||
|
if (opts.env.length > 0) {
|
||||||
|
console.log(`${c.info('▶')} Setting environment variables...`);
|
||||||
|
const exports = opts.env
|
||||||
|
.filter(e => /^\w+=.+$/.test(e))
|
||||||
|
.map(e => `export ${e}`)
|
||||||
|
.join('\n');
|
||||||
|
if (exports) {
|
||||||
|
sbx(['exec', opts.name, 'bash', '-c', `echo '${exports}' >> /etc/sandbox-persistent.sh`]);
|
||||||
|
}
|
||||||
|
const invalid = opts.env.filter(e => !/^\w+=.+$/.test(e));
|
||||||
|
if (invalid.length > 0) {
|
||||||
|
console.log(`${c.warn('⚠')} Skipped invalid env vars: ${invalid.join(', ')} (expected KEY=VALUE)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Start CloudCLI inside the sandbox
|
||||||
|
console.log(`${c.info('▶')} Launching CloudCLI web server...`);
|
||||||
|
sbx(['exec', opts.name, 'bash', '-c', 'cloudcli start --port 3001 &']);
|
||||||
|
|
||||||
|
// Step 4: Forward port
|
||||||
|
console.log(`${c.info('▶')} Forwarding port ${opts.port} → 3001...`);
|
||||||
|
try {
|
||||||
|
sbx(['ports', opts.name, '--publish', `${opts.port}:3001`]);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e.stdout || e.stderr || e.message || '';
|
||||||
|
if (msg.includes('address already in use')) {
|
||||||
|
const altPort = opts.port + 1;
|
||||||
|
console.log(`${c.warn('⚠')} Port ${opts.port} in use, trying ${altPort}...`);
|
||||||
|
try {
|
||||||
|
sbx(['ports', opts.name, '--publish', `${altPort}:3001`]);
|
||||||
|
opts.port = altPort;
|
||||||
|
} catch {
|
||||||
|
console.error(`${c.error('❌')} Ports ${opts.port} and ${altPort} both in use. Use --port to specify a free port.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done
|
||||||
|
console.log(`\n${c.ok('✔')} ${c.bright('CloudCLI is ready!')}`);
|
||||||
|
console.log(` ${c.info('→')} Open ${c.bright(`http://localhost:${opts.port}`)}`);
|
||||||
|
console.log(`\n${c.dim(' Manage with:')}`);
|
||||||
|
console.log(` ${c.dim('$')} sbx ls`);
|
||||||
|
console.log(` ${c.dim('$')} sbx stop ${opts.name}`);
|
||||||
|
console.log(` ${c.dim('$')} sbx start ${opts.name}`);
|
||||||
|
console.log(` ${c.dim('$')} sbx rm ${opts.name}`);
|
||||||
|
console.log(`\n${c.dim(' Or install globally:')} npm install -g @cloudcli-ai/cloudcli\n`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
showSandboxHelp();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Server ──────────────────────────────────────────────────
|
||||||
|
|
||||||
// Start the server
|
// Start the server
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
// Check for updates silently on startup
|
// Check for updates silently on startup
|
||||||
@@ -274,6 +622,10 @@ function parseArgs(args) {
|
|||||||
parsed.command = 'version';
|
parsed.command = 'version';
|
||||||
} else if (!arg.startsWith('-')) {
|
} else if (!arg.startsWith('-')) {
|
||||||
parsed.command = arg;
|
parsed.command = arg;
|
||||||
|
if (arg === 'sandbox') {
|
||||||
|
parsed.remainingArgs = args.slice(i + 1);
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +635,7 @@ function parseArgs(args) {
|
|||||||
// Main CLI handler
|
// Main CLI handler
|
||||||
async function main() {
|
async function main() {
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
const { command, options } = parseArgs(args);
|
const { command, options, remainingArgs } = parseArgs(args);
|
||||||
|
|
||||||
// Apply CLI options to environment variables
|
// Apply CLI options to environment variables
|
||||||
if (options.serverPort) {
|
if (options.serverPort) {
|
||||||
@@ -299,6 +651,9 @@ async function main() {
|
|||||||
case 'start':
|
case 'start':
|
||||||
await startServer();
|
await startServer();
|
||||||
break;
|
break;
|
||||||
|
case 'sandbox':
|
||||||
|
await sandboxCommand(remainingArgs || []);
|
||||||
|
break;
|
||||||
case 'status':
|
case 'status':
|
||||||
case 'info':
|
case 'info':
|
||||||
showStatus();
|
showStatus();
|
||||||
|
|||||||
@@ -435,13 +435,20 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
|
|||||||
|
|
||||||
console.log('Starting system update from directory:', projectRoot);
|
console.log('Starting system update from directory:', projectRoot);
|
||||||
|
|
||||||
// Run the update command based on install mode
|
// Platform deployments use their own update workflow from the project root.
|
||||||
const updateCommand = installMode === 'git'
|
const updateCommand = IS_PLATFORM
|
||||||
? 'git checkout main && git pull && npm install'
|
// In platform, husky and dev dependencies are not needed
|
||||||
: 'npm install -g @cloudcli-ai/cloudcli@latest';
|
? 'npm run update:platform'
|
||||||
|
: installMode === 'git'
|
||||||
|
? 'git checkout main && git pull && npm install'
|
||||||
|
: 'npm install -g @cloudcli-ai/cloudcli@latest';
|
||||||
|
|
||||||
|
const updateCwd = IS_PLATFORM || installMode === 'git'
|
||||||
|
? projectRoot
|
||||||
|
: os.homedir();
|
||||||
|
|
||||||
const child = spawn('sh', ['-c', updateCommand], {
|
const child = spawn('sh', ['-c', updateCommand], {
|
||||||
cwd: installMode === 'git' ? projectRoot : os.homedir(),
|
cwd: updateCwd,
|
||||||
env: process.env
|
env: process.env
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import express from 'express';
|
|||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import os from 'os';
|
import os from 'os';
|
||||||
import { spawn } from 'child_process';
|
|
||||||
import sqlite3 from 'sqlite3';
|
import sqlite3 from 'sqlite3';
|
||||||
import { open } from 'sqlite';
|
import { open } from 'sqlite';
|
||||||
import crypto from 'crypto';
|
import crypto from 'crypto';
|
||||||
@@ -578,221 +577,4 @@ router.get('/sessions', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/cursor/sessions/:sessionId - Get specific Cursor session from SQLite
|
|
||||||
router.get('/sessions/:sessionId', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { sessionId } = req.params;
|
|
||||||
const { projectPath } = req.query;
|
|
||||||
|
|
||||||
// Calculate cwdID hash for the project path
|
|
||||||
const cwdId = crypto.createHash('md5').update(projectPath || process.cwd()).digest('hex');
|
|
||||||
const storeDbPath = path.join(os.homedir(), '.cursor', 'chats', cwdId, sessionId, 'store.db');
|
|
||||||
|
|
||||||
|
|
||||||
// Open SQLite database
|
|
||||||
const db = await open({
|
|
||||||
filename: storeDbPath,
|
|
||||||
driver: sqlite3.Database,
|
|
||||||
mode: sqlite3.OPEN_READONLY
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get all blobs to build the DAG structure
|
|
||||||
const allBlobs = await db.all(`
|
|
||||||
SELECT rowid, id, data FROM blobs
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Build the DAG structure from parent-child relationships
|
|
||||||
const blobMap = new Map(); // id -> blob data
|
|
||||||
const parentRefs = new Map(); // blob id -> [parent blob ids]
|
|
||||||
const childRefs = new Map(); // blob id -> [child blob ids]
|
|
||||||
const jsonBlobs = []; // Clean JSON messages
|
|
||||||
|
|
||||||
for (const blob of allBlobs) {
|
|
||||||
blobMap.set(blob.id, blob);
|
|
||||||
|
|
||||||
// Check if this is a JSON blob (actual message) or protobuf (DAG structure)
|
|
||||||
if (blob.data && blob.data[0] === 0x7B) { // Starts with '{' - JSON blob
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(blob.data.toString('utf8'));
|
|
||||||
jsonBlobs.push({ ...blob, parsed });
|
|
||||||
} catch (e) {
|
|
||||||
console.log('Failed to parse JSON blob:', blob.rowid);
|
|
||||||
}
|
|
||||||
} else if (blob.data) { // Protobuf blob - extract parent references
|
|
||||||
const parents = [];
|
|
||||||
let i = 0;
|
|
||||||
|
|
||||||
// Scan for parent references (0x0A 0x20 followed by 32-byte hash)
|
|
||||||
while (i < blob.data.length - 33) {
|
|
||||||
if (blob.data[i] === 0x0A && blob.data[i+1] === 0x20) {
|
|
||||||
const parentHash = blob.data.slice(i+2, i+34).toString('hex');
|
|
||||||
if (blobMap.has(parentHash)) {
|
|
||||||
parents.push(parentHash);
|
|
||||||
}
|
|
||||||
i += 34;
|
|
||||||
} else {
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parents.length > 0) {
|
|
||||||
parentRefs.set(blob.id, parents);
|
|
||||||
// Update child references
|
|
||||||
for (const parentId of parents) {
|
|
||||||
if (!childRefs.has(parentId)) {
|
|
||||||
childRefs.set(parentId, []);
|
|
||||||
}
|
|
||||||
childRefs.get(parentId).push(blob.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Perform topological sort to get chronological order
|
|
||||||
const visited = new Set();
|
|
||||||
const sorted = [];
|
|
||||||
|
|
||||||
// DFS-based topological sort
|
|
||||||
function visit(nodeId) {
|
|
||||||
if (visited.has(nodeId)) return;
|
|
||||||
visited.add(nodeId);
|
|
||||||
|
|
||||||
// Visit all parents first (dependencies)
|
|
||||||
const parents = parentRefs.get(nodeId) || [];
|
|
||||||
for (const parentId of parents) {
|
|
||||||
visit(parentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add this node after all its parents
|
|
||||||
const blob = blobMap.get(nodeId);
|
|
||||||
if (blob) {
|
|
||||||
sorted.push(blob);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start with nodes that have no parents (roots)
|
|
||||||
for (const blob of allBlobs) {
|
|
||||||
if (!parentRefs.has(blob.id)) {
|
|
||||||
visit(blob.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Visit any remaining nodes (disconnected components)
|
|
||||||
for (const blob of allBlobs) {
|
|
||||||
visit(blob.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now extract JSON messages in the order they appear in the sorted DAG
|
|
||||||
const messageOrder = new Map(); // JSON blob id -> order index
|
|
||||||
let orderIndex = 0;
|
|
||||||
|
|
||||||
for (const blob of sorted) {
|
|
||||||
// Check if this blob references any JSON messages
|
|
||||||
if (blob.data && blob.data[0] !== 0x7B) { // Protobuf blob
|
|
||||||
// Look for JSON blob references
|
|
||||||
for (const jsonBlob of jsonBlobs) {
|
|
||||||
try {
|
|
||||||
const jsonIdBytes = Buffer.from(jsonBlob.id, 'hex');
|
|
||||||
if (blob.data.includes(jsonIdBytes)) {
|
|
||||||
if (!messageOrder.has(jsonBlob.id)) {
|
|
||||||
messageOrder.set(jsonBlob.id, orderIndex++);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Skip if can't convert ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort JSON blobs by their appearance order in the DAG
|
|
||||||
const sortedJsonBlobs = jsonBlobs.sort((a, b) => {
|
|
||||||
const orderA = messageOrder.get(a.id) ?? Number.MAX_SAFE_INTEGER;
|
|
||||||
const orderB = messageOrder.get(b.id) ?? Number.MAX_SAFE_INTEGER;
|
|
||||||
if (orderA !== orderB) return orderA - orderB;
|
|
||||||
// Fallback to rowid if not in order map
|
|
||||||
return a.rowid - b.rowid;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Use sorted JSON blobs
|
|
||||||
const blobs = sortedJsonBlobs.map((blob, idx) => ({
|
|
||||||
...blob,
|
|
||||||
sequence_num: idx + 1,
|
|
||||||
original_rowid: blob.rowid
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Get metadata from meta table
|
|
||||||
const metaRows = await db.all(`
|
|
||||||
SELECT key, value FROM meta
|
|
||||||
`);
|
|
||||||
|
|
||||||
// Parse metadata
|
|
||||||
let metadata = {};
|
|
||||||
for (const row of metaRows) {
|
|
||||||
if (row.value) {
|
|
||||||
try {
|
|
||||||
// Try to decode as hex-encoded JSON
|
|
||||||
const hexMatch = row.value.toString().match(/^[0-9a-fA-F]+$/);
|
|
||||||
if (hexMatch) {
|
|
||||||
const jsonStr = Buffer.from(row.value, 'hex').toString('utf8');
|
|
||||||
metadata[row.key] = JSON.parse(jsonStr);
|
|
||||||
} else {
|
|
||||||
metadata[row.key] = row.value.toString();
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
metadata[row.key] = row.value.toString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract messages from sorted JSON blobs
|
|
||||||
const messages = [];
|
|
||||||
for (const blob of blobs) {
|
|
||||||
try {
|
|
||||||
// We already parsed JSON blobs earlier
|
|
||||||
const parsed = blob.parsed;
|
|
||||||
|
|
||||||
if (parsed) {
|
|
||||||
// Filter out ONLY system messages at the server level
|
|
||||||
// Check both direct role and nested message.role
|
|
||||||
const role = parsed?.role || parsed?.message?.role;
|
|
||||||
if (role === 'system') {
|
|
||||||
continue; // Skip only system messages
|
|
||||||
}
|
|
||||||
messages.push({
|
|
||||||
id: blob.id,
|
|
||||||
sequence: blob.sequence_num,
|
|
||||||
rowid: blob.original_rowid,
|
|
||||||
content: parsed
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
// Skip blobs that cause errors
|
|
||||||
console.log(`Skipping blob ${blob.id}: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await db.close();
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
success: true,
|
|
||||||
session: {
|
|
||||||
id: sessionId,
|
|
||||||
projectPath: projectPath,
|
|
||||||
messages: messages,
|
|
||||||
metadata: metadata,
|
|
||||||
cwdId: cwdId
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error reading Cursor session:', error);
|
|
||||||
res.status(500).json({
|
|
||||||
error: 'Failed to read Cursor session',
|
|
||||||
details: error.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
@@ -18,9 +18,10 @@ export const CLAUDE_MODELS = {
|
|||||||
{ value: "haiku", label: "Haiku" },
|
{ value: "haiku", label: "Haiku" },
|
||||||
{ value: "opusplan", label: "Opus Plan" },
|
{ value: "opusplan", label: "Opus Plan" },
|
||||||
{ value: "sonnet[1m]", label: "Sonnet [1M]" },
|
{ value: "sonnet[1m]", label: "Sonnet [1M]" },
|
||||||
|
{ value: "opus[1m]", label: "Opus [1M]" },
|
||||||
],
|
],
|
||||||
|
|
||||||
DEFAULT: "sonnet",
|
DEFAULT: "opus",
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +59,7 @@ export const CURSOR_MODELS = {
|
|||||||
export const CODEX_MODELS = {
|
export const CODEX_MODELS = {
|
||||||
OPTIONS: [
|
OPTIONS: [
|
||||||
{ value: "gpt-5.4", label: "GPT-5.4" },
|
{ value: "gpt-5.4", label: "GPT-5.4" },
|
||||||
|
{ value: "gpt-5.4-mini", label: "GPT-5.4 mini" },
|
||||||
{ 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-codex", label: "GPT-5.2 Codex" },
|
||||||
{ value: "gpt-5.2", label: "GPT-5.2" },
|
{ value: "gpt-5.2", label: "GPT-5.2" },
|
||||||
@@ -88,5 +90,5 @@ export const GEMINI_MODELS = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|
||||||
DEFAULT: "gemini-2.5-flash",
|
DEFAULT: "gemini-3.1-pro-preview",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect, useCallback, type CSSProperties } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { Brain, X } from 'lucide-react';
|
import { Brain, X } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { thinkingModes } from '../../constants/thinkingModes';
|
import { thinkingModes } from '../../constants/thinkingModes';
|
||||||
@@ -12,6 +13,11 @@ type ThinkingModeSelectorProps = {
|
|||||||
|
|
||||||
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {
|
function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className = '' }: ThinkingModeSelectorProps) {
|
||||||
const { t } = useTranslation('chat');
|
const { t } = useTranslation('chat');
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [dropdownStyle, setDropdownStyle] = useState<CSSProperties | null>(null);
|
||||||
|
|
||||||
// Mapping from mode ID to translation key
|
// Mapping from mode ID to translation key
|
||||||
const modeKeyMap: Record<string, string> = {
|
const modeKeyMap: Record<string, string> = {
|
||||||
@@ -29,50 +35,143 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const closeDropdown = useCallback(() => {
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
setIsOpen(false);
|
||||||
|
onClose?.();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const updateDropdownPosition = useCallback(() => {
|
||||||
|
const trigger = triggerRef.current;
|
||||||
|
const dropdown = dropdownRef.current;
|
||||||
|
if (!trigger || !dropdown || typeof window === 'undefined') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const triggerRect = trigger.getBoundingClientRect();
|
||||||
|
const viewportPadding = window.innerWidth < 640 ? 12 : 16;
|
||||||
|
const spacing = 8;
|
||||||
|
const width = Math.min(window.innerWidth - viewportPadding * 2, window.innerWidth < 640 ? 320 : 256);
|
||||||
|
let left = triggerRect.left + triggerRect.width / 2 - width / 2;
|
||||||
|
left = Math.max(viewportPadding, Math.min(left, window.innerWidth - width - viewportPadding));
|
||||||
|
|
||||||
|
const measuredHeight = dropdown.offsetHeight || 0;
|
||||||
|
const spaceBelow = window.innerHeight - triggerRect.bottom - spacing - viewportPadding;
|
||||||
|
const spaceAbove = triggerRect.top - spacing - viewportPadding;
|
||||||
|
const openBelow = spaceBelow >= Math.min(measuredHeight || 320, 320) || spaceBelow >= spaceAbove;
|
||||||
|
const availableHeight = Math.min(
|
||||||
|
window.innerHeight - viewportPadding * 2,
|
||||||
|
Math.max(180, openBelow ? spaceBelow : spaceAbove),
|
||||||
|
);
|
||||||
|
const panelHeight = Math.min(measuredHeight || availableHeight, availableHeight);
|
||||||
|
const top = openBelow
|
||||||
|
? Math.min(triggerRect.bottom + spacing, window.innerHeight - viewportPadding - panelHeight)
|
||||||
|
: Math.max(viewportPadding, triggerRect.top - spacing - panelHeight);
|
||||||
|
|
||||||
|
setDropdownStyle({
|
||||||
|
position: 'fixed',
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
width,
|
||||||
|
maxHeight: availableHeight,
|
||||||
|
zIndex: 80,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
if (!isOpen) {
|
||||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
setDropdownStyle(null);
|
||||||
setIsOpen(false);
|
return;
|
||||||
if (onClose) onClose();
|
}
|
||||||
|
|
||||||
|
const rafId = window.requestAnimationFrame(updateDropdownPosition);
|
||||||
|
const handleViewportChange = () => updateDropdownPosition();
|
||||||
|
|
||||||
|
window.addEventListener('resize', handleViewportChange);
|
||||||
|
window.addEventListener('scroll', handleViewportChange, true);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(rafId);
|
||||||
|
window.removeEventListener('resize', handleViewportChange);
|
||||||
|
window.removeEventListener('scroll', handleViewportChange, true);
|
||||||
|
};
|
||||||
|
}, [isOpen, updateDropdownPosition]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerDown = (event: PointerEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (!(target instanceof Node)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (containerRef.current?.contains(target) || dropdownRef.current?.contains(target)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
closeDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
closeDropdown();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
document.addEventListener('mousedown', handleClickOutside);
|
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
document.addEventListener('keydown', handleKeyDown);
|
||||||
}, [onClose]);
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||||
|
document.removeEventListener('keydown', handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [isOpen, closeDropdown]);
|
||||||
|
|
||||||
const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0];
|
const currentMode = translatedModes.find(mode => mode.id === selectedMode) || translatedModes[0];
|
||||||
const IconComponent = currentMode.icon || Brain;
|
const IconComponent = currentMode.icon || Brain;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative ${className}`} ref={dropdownRef}>
|
<div className={`relative ${className}`} ref={containerRef}>
|
||||||
<button
|
<button
|
||||||
|
ref={triggerRef}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsOpen(!isOpen)}
|
onClick={() => {
|
||||||
|
if (isOpen) {
|
||||||
|
closeDropdown();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
className={`flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 sm:h-10 sm:w-10 ${selectedMode === 'none'
|
className={`flex h-10 w-10 items-center justify-center rounded-full transition-all duration-200 sm:h-10 sm:w-10 ${selectedMode === 'none'
|
||||||
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
|
? 'bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600'
|
||||||
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
|
: 'bg-blue-100 hover:bg-blue-200 dark:bg-blue-900 dark:hover:bg-blue-800'
|
||||||
}`}
|
}`}
|
||||||
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
|
title={t('thinkingMode.buttonTitle', { mode: currentMode.name })}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
aria-expanded={isOpen}
|
||||||
>
|
>
|
||||||
<IconComponent className={`h-5 w-5 ${currentMode.color}`} />
|
<IconComponent className={`h-5 w-5 ${currentMode.color}`} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{isOpen && (
|
{isOpen && typeof document !== 'undefined' && createPortal(
|
||||||
<div className="absolute bottom-full right-0 mb-2 w-64 overflow-hidden rounded-lg border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800">
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
style={dropdownStyle || { position: 'fixed', top: 0, left: 0, visibility: 'hidden' }}
|
||||||
|
className="flex flex-col overflow-hidden rounded-xl border border-gray-200 bg-white shadow-xl dark:border-gray-700 dark:bg-gray-800"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="false"
|
||||||
|
>
|
||||||
<div className="border-b border-gray-200 p-3 dark:border-gray-700">
|
<div className="border-b border-gray-200 p-3 dark:border-gray-700">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
{t('thinkingMode.selector.title')}
|
{t('thinkingMode.selector.title')}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
type="button"
|
||||||
setIsOpen(false);
|
onClick={closeDropdown}
|
||||||
if (onClose) onClose();
|
|
||||||
}}
|
|
||||||
className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
|
className="rounded p-1 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4 text-gray-500" />
|
<X className="h-4 w-4 text-gray-500" />
|
||||||
@@ -83,7 +182,7 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="py-1">
|
<div className="min-h-0 overflow-y-auto py-1">
|
||||||
{translatedModes.map((mode) => {
|
{translatedModes.map((mode) => {
|
||||||
const ModeIcon = mode.icon;
|
const ModeIcon = mode.icon;
|
||||||
const isSelected = mode.id === selectedMode;
|
const isSelected = mode.id === selectedMode;
|
||||||
@@ -91,10 +190,10 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={mode.id}
|
key={mode.id}
|
||||||
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onModeChange(mode.id);
|
onModeChange(mode.id);
|
||||||
setIsOpen(false);
|
closeDropdown();
|
||||||
if (onClose) onClose();
|
|
||||||
}}
|
}}
|
||||||
className={`w-full px-4 py-3 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''
|
className={`w-full px-4 py-3 text-left transition-colors hover:bg-gray-50 dark:hover:bg-gray-700 ${isSelected ? 'bg-gray-50 dark:bg-gray-700' : ''
|
||||||
}`}
|
}`}
|
||||||
@@ -135,10 +234,11 @@ function ThinkingModeSelector({ selectedMode, onModeChange, onClose, className =
|
|||||||
<strong>Tip:</strong> {t('thinkingMode.selector.tip')}
|
<strong>Tip:</strong> {t('thinkingMode.selector.tip')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default ThinkingModeSelector;
|
export default ThinkingModeSelector;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { authenticatedFetch } from "../../../utils/api";
|
import { authenticatedFetch } from "../../../utils/api";
|
||||||
import { ReleaseInfo } from "../../../types/sharedTypes";
|
import { ReleaseInfo } from "../../../types/sharedTypes";
|
||||||
import { copyTextToClipboard } from "../../../utils/clipboard";
|
import { copyTextToClipboard } from "../../../utils/clipboard";
|
||||||
import type { InstallMode } from "../../../hooks/useVersionCheck";
|
import type { InstallMode } from "../../../hooks/useVersionCheck";
|
||||||
|
import { IS_PLATFORM } from "../../../constants/config";
|
||||||
|
|
||||||
interface VersionUpgradeModalProps {
|
interface VersionUpgradeModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -14,6 +15,8 @@ interface VersionUpgradeModalProps {
|
|||||||
installMode: InstallMode;
|
installMode: InstallMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RELOAD_COUNTDOWN_START = 30;
|
||||||
|
|
||||||
export function VersionUpgradeModal({
|
export function VersionUpgradeModal({
|
||||||
isOpen,
|
isOpen,
|
||||||
onClose,
|
onClose,
|
||||||
@@ -25,14 +28,36 @@ export function VersionUpgradeModal({
|
|||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
const upgradeCommand = installMode === 'npm'
|
const upgradeCommand = installMode === 'npm'
|
||||||
? t('versionUpdate.npmUpgradeCommand')
|
? t('versionUpdate.npmUpgradeCommand')
|
||||||
: 'git checkout main && git pull && npm install';
|
: IS_PLATFORM
|
||||||
|
? 'npm run update:platform'
|
||||||
|
: 'git checkout main && git pull && npm install';
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const [updateOutput, setUpdateOutput] = useState('');
|
const [updateOutput, setUpdateOutput] = useState('');
|
||||||
const [updateError, setUpdateError] = useState('');
|
const [updateError, setUpdateError] = useState('');
|
||||||
|
const [reloadCountdown, setReloadCountdown] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!IS_PLATFORM || reloadCountdown === null || reloadCountdown <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setReloadCountdown((previousCountdown) => {
|
||||||
|
if (previousCountdown === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(previousCountdown - 1, 0);
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
return () => window.clearTimeout(timeoutId);
|
||||||
|
}, [reloadCountdown]);
|
||||||
|
|
||||||
const handleUpdateNow = useCallback(async () => {
|
const handleUpdateNow = useCallback(async () => {
|
||||||
setIsUpdating(true);
|
setIsUpdating(true);
|
||||||
setUpdateOutput('Starting update...\n');
|
setUpdateOutput('Starting update...\n');
|
||||||
|
setReloadCountdown(IS_PLATFORM ? RELOAD_COUNTDOWN_START : null);
|
||||||
setUpdateError('');
|
setUpdateError('');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -46,7 +71,7 @@ export function VersionUpgradeModal({
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
setUpdateOutput(prev => prev + data.output + '\n');
|
setUpdateOutput(prev => prev + data.output + '\n');
|
||||||
setUpdateOutput(prev => prev + '\n✅ Update completed successfully!\n');
|
setUpdateOutput(prev => prev + '\n✅ Update completed successfully!\n');
|
||||||
setUpdateOutput(prev => prev + 'Please restart the server to apply changes.\n');
|
setUpdateOutput(prev => prev + 'Please restart the server to apply changes.' + '\n');
|
||||||
} else {
|
} else {
|
||||||
setUpdateError(data.error || 'Update failed');
|
setUpdateError(data.error || 'Update failed');
|
||||||
setUpdateOutput(prev => prev + '\n❌ Update failed: ' + (data.error || 'Unknown error') + '\n');
|
setUpdateOutput(prev => prev + '\n❌ Update failed: ' + (data.error || 'Unknown error') + '\n');
|
||||||
@@ -143,6 +168,13 @@ export function VersionUpgradeModal({
|
|||||||
<div className="max-h-48 overflow-y-auto rounded-lg border border-gray-700 bg-gray-900 p-4 dark:bg-gray-950">
|
<div className="max-h-48 overflow-y-auto rounded-lg border border-gray-700 bg-gray-900 p-4 dark:bg-gray-950">
|
||||||
<pre className="whitespace-pre-wrap font-mono text-xs text-green-400">{updateOutput}</pre>
|
<pre className="whitespace-pre-wrap font-mono text-xs text-green-400">{updateOutput}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
{IS_PLATFORM && reloadCountdown !== null && (
|
||||||
|
<div className="rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-700 dark:border-blue-900/40 dark:bg-blue-900/20 dark:text-blue-200">
|
||||||
|
{reloadCountdown === 0
|
||||||
|
? 'Refresh the page now. If that doesn\'t work, RESTART the environment.'
|
||||||
|
: `Refresh the page in ${reloadCountdown} ${reloadCountdown === 1 ? 'second' : 'seconds'}. If that doesn\'t work, RESTART the environment.`}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{updateError && (
|
{updateError && (
|
||||||
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-200">
|
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-xs text-red-700 dark:border-red-900/40 dark:bg-red-900/20 dark:text-red-200">
|
||||||
{updateError}
|
{updateError}
|
||||||
|
|||||||
Reference in New Issue
Block a user