Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e482452ffd | |||
| 8c710fc7bf | |||
| b7585420ef | |||
| bf8ef3dba1 | |||
| 418afe00ed | |||
| ab16ac11e7 | |||
| 429acd2fb5 | |||
| c853f2676d | |||
| 090aad6bc6 | |||
| c023d80c86 | |||
| 33f02e65c0 |
@@ -10,6 +10,7 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "app/**"
|
- "app/**"
|
||||||
|
- ".gitea/workflows/build-app.yml"
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "container/**"
|
- "container/**"
|
||||||
|
- ".gitea/workflows/build.yml"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "container/**"
|
- "container/**"
|
||||||
|
- ".gitea/workflows/build.yml"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: repo.anhonesthost.net
|
REGISTRY: repo.anhonesthost.net
|
||||||
|
|||||||
202
HOW-TO-USE.md
202
HOW-TO-USE.md
@@ -65,11 +65,11 @@ Switch to the **Projects** tab in the sidebar and click the **+** button.
|
|||||||
|
|
||||||
### 3. Start the Container
|
### 3. Start the Container
|
||||||
|
|
||||||
Select your project in the sidebar and click **Start**. The status dot changes from gray (stopped) to orange (starting) to green (running).
|
Select your project in the sidebar and click **Start**. A progress modal appears showing real-time status as the container starts. The status dot changes from gray (stopped) to orange (starting) to green (running). The modal auto-closes on success.
|
||||||
|
|
||||||
### 4. Open a Terminal
|
### 4. Open a Terminal
|
||||||
|
|
||||||
Click the **Terminal** button (highlighted in accent color) to open an interactive terminal session. A new tab appears in the top bar and an xterm.js terminal loads in the main area.
|
Click the **Terminal** button to open an interactive terminal session. A new tab appears in the top bar and an xterm.js terminal loads in the main area.
|
||||||
|
|
||||||
Claude Code launches automatically with `--dangerously-skip-permissions` inside the sandboxed container.
|
Claude Code launches automatically with `--dangerously-skip-permissions` inside the sandboxed container.
|
||||||
|
|
||||||
@@ -99,16 +99,16 @@ Claude Code launches automatically with `--dangerously-skip-permissions` inside
|
|||||||
│ Sidebar │ │
|
│ Sidebar │ │
|
||||||
│ │ Terminal View │
|
│ │ Terminal View │
|
||||||
│ Projects │ (xterm.js) │
|
│ Projects │ (xterm.js) │
|
||||||
|
│ MCP │ │
|
||||||
│ Settings │ │
|
│ Settings │ │
|
||||||
│ │ │
|
|
||||||
├────────────┴────────────────────────────────────────┤
|
├────────────┴────────────────────────────────────────┤
|
||||||
│ StatusBar X projects · X running · X terminals │
|
│ StatusBar X projects · X running · X terminals │
|
||||||
└─────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
- **TopBar** — Terminal tabs for switching between sessions. Status dots on the right show Docker connection (green = connected) and image availability (green = ready).
|
- **TopBar** — Terminal tabs for switching between sessions. Bash shell tabs show a "(bash)" suffix. Status dots on the right show Docker connection (green = connected) and image availability (green = ready).
|
||||||
- **Sidebar** — Toggle between the **Projects** list and **Settings** panel.
|
- **Sidebar** — Toggle between the **Projects** list, **MCP** server configuration, and **Settings** panel.
|
||||||
- **Terminal View** — Interactive terminal powered by xterm.js with WebGL rendering.
|
- **Terminal View** — Interactive terminal powered by xterm.js with WebGL rendering. Includes a **Jump to Current** button that appears when you scroll up, so you can quickly return to the latest output.
|
||||||
- **StatusBar** — Counts of total projects, running containers, and open terminal sessions.
|
- **StatusBar** — Counts of total projects, running containers, and open terminal sessions.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -134,11 +134,17 @@ Select a project in the sidebar to see its action buttons:
|
|||||||
|--------|---------------|--------------|
|
|--------|---------------|--------------|
|
||||||
| **Start** | Stopped | Creates (if needed) and starts the container |
|
| **Start** | Stopped | Creates (if needed) and starts the container |
|
||||||
| **Stop** | Running | Stops the container but preserves its state |
|
| **Stop** | Running | Stops the container but preserves its state |
|
||||||
| **Terminal** | Running | Opens a new terminal session in this container |
|
| **Terminal** | Running | Opens a new Claude Code terminal session |
|
||||||
|
| **Shell** | Running | Opens a bash login shell in the container (no Claude Code) |
|
||||||
|
| **Files** | Running | Opens the file manager to browse, download, and upload files |
|
||||||
| **Reset** | Stopped | Destroys and recreates the container from scratch |
|
| **Reset** | Stopped | Destroys and recreates the container from scratch |
|
||||||
| **Config** | Always | Toggles the configuration panel |
|
| **Config** | Always | Toggles the configuration panel |
|
||||||
| **Remove** | Stopped | Deletes the project and its container (with confirmation) |
|
| **Remove** | Stopped | Deletes the project and its container (with confirmation) |
|
||||||
|
|
||||||
|
### Renaming a Project
|
||||||
|
|
||||||
|
Double-click the project name in the sidebar to rename it inline. Press **Enter** to confirm or **Escape** to cancel.
|
||||||
|
|
||||||
### Container Lifecycle
|
### Container Lifecycle
|
||||||
|
|
||||||
Containers use a **stop/start** model. When you stop a container, everything inside it is preserved — installed packages, modified files, downloaded tools. Starting it again resumes where you left off.
|
Containers use a **stop/start** model. When you stop a container, everything inside it is preserved — installed packages, modified files, downloaded tools. Starting it again resumes where you left off.
|
||||||
@@ -147,6 +153,10 @@ Containers use a **stop/start** model. When you stop a container, everything ins
|
|||||||
|
|
||||||
Only **Remove** deletes everything, including the config volume and any stored credentials.
|
Only **Remove** deletes everything, including the config volume and any stored credentials.
|
||||||
|
|
||||||
|
### Container Progress Feedback
|
||||||
|
|
||||||
|
When starting, stopping, or resetting a container, a progress modal shows real-time status messages (e.g., "Setting up MCP network...", "Starting MCP containers...", "Creating container..."). If an error occurs, the modal displays the error with a **Close** button. A **Force Stop** option is available if the operation stalls. The modal auto-closes on success.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Project Configuration
|
## Project Configuration
|
||||||
@@ -177,6 +187,19 @@ When enabled, the host Docker socket is mounted into the container so Claude Cod
|
|||||||
|
|
||||||
> Toggling this requires stopping and restarting the container to take effect.
|
> Toggling this requires stopping and restarting the container to take effect.
|
||||||
|
|
||||||
|
### Mission Control
|
||||||
|
|
||||||
|
Toggle **Mission Control** to integrate [Flight Control](https://github.com/msieurthenardier/mission-control) — an AI-first development methodology — into the project. When enabled:
|
||||||
|
|
||||||
|
- The Flight Control repository is automatically cloned into the container
|
||||||
|
- Flight Control skills are installed to Claude Code's skill directory (`~/.claude/skills/`)
|
||||||
|
- Project instructions are appended with Flight Control workflow guidance
|
||||||
|
- The repository is symlinked at `/workspace/mission-control`
|
||||||
|
|
||||||
|
Available skills include `/mission`, `/flight`, `/leg`, `/agentic-workflow`, `/flight-debrief`, `/mission-debrief`, `/daily-briefing`, and `/init-project`.
|
||||||
|
|
||||||
|
> This setting can only be changed when the container is stopped. Toggling it triggers a container recreation on the next start.
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
Click **Edit** to open the environment variables modal. Add key-value pairs that will be injected into the container. Per-project variables override global variables with the same key.
|
Click **Edit** to open the environment variables modal. Add key-value pairs that will be injected into the container. Per-project variables override global variables with the same key.
|
||||||
@@ -188,8 +211,8 @@ Click **Edit** to open the environment variables modal. Add key-value pairs that
|
|||||||
Click **Edit** to map host ports to container ports. This is useful when Claude Code starts a web server or other service inside the container and you want to access it from your host browser.
|
Click **Edit** to map host ports to container ports. This is useful when Claude Code starts a web server or other service inside the container and you want to access it from your host browser.
|
||||||
|
|
||||||
Each mapping specifies:
|
Each mapping specifies:
|
||||||
- **Host Port** — The port on your machine (1–65535)
|
- **Host Port** — The port on your machine (1-65535)
|
||||||
- **Container Port** — The port inside the container (1–65535)
|
- **Container Port** — The port inside the container (1-65535)
|
||||||
- **Protocol** — TCP (default) or UDP
|
- **Protocol** — TCP (default) or UDP
|
||||||
|
|
||||||
### Claude Instructions
|
### Claude Instructions
|
||||||
@@ -198,6 +221,128 @@ Click **Edit** to write per-project instructions for Claude Code. These are writ
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## MCP Servers (Beta)
|
||||||
|
|
||||||
|
Triple-C supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers, which extend Claude Code with access to external tools and data sources. MCP servers are configured in a **global library** and **enabled per-project**.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
There are two dimensions to MCP server configuration:
|
||||||
|
|
||||||
|
| | **Manual** (no Docker image) | **Docker** (Docker image specified) |
|
||||||
|
|---|---|---|
|
||||||
|
| **Stdio** | Command runs inside the project container | Command runs in a separate MCP container via `docker exec` |
|
||||||
|
| **HTTP** | Connects to a URL you provide | Runs in a separate container, reached by hostname on a shared Docker network |
|
||||||
|
|
||||||
|
**Docker images are pulled automatically** if not already present when the project starts.
|
||||||
|
|
||||||
|
### Accessing MCP Configuration
|
||||||
|
|
||||||
|
Click the **MCP** tab in the sidebar to open the MCP server library. This is where you define all available MCP servers.
|
||||||
|
|
||||||
|
### Adding an MCP Server
|
||||||
|
|
||||||
|
1. Type a name in the input field and click **Add**.
|
||||||
|
2. Expand the server card and configure it.
|
||||||
|
|
||||||
|
The key decision is whether to set a **Docker Image**:
|
||||||
|
- **With Docker image** — The MCP server runs in its own isolated container. Best for servers that need specific dependencies or system-level packages.
|
||||||
|
- **Without Docker image** (manual) — The command runs directly inside your project container. Best for lightweight npx-based servers that just need Node.js.
|
||||||
|
|
||||||
|
Then choose the **Transport Type**:
|
||||||
|
- **Stdio** — The MCP server communicates over stdin/stdout. This is the most common type.
|
||||||
|
- **HTTP** — The MCP server exposes an HTTP endpoint (streamable HTTP transport).
|
||||||
|
|
||||||
|
### Configuration Examples
|
||||||
|
|
||||||
|
#### Example 1: Filesystem Server (Stdio, Manual)
|
||||||
|
|
||||||
|
A simple npx-based server that runs inside the project container. No Docker image needed since Node.js is already installed.
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| **Docker Image** | *(empty)* |
|
||||||
|
| **Transport** | Stdio |
|
||||||
|
| **Command** | `npx` |
|
||||||
|
| **Arguments** | `-y @modelcontextprotocol/server-filesystem /workspace` |
|
||||||
|
|
||||||
|
This gives Claude Code access to browse and read files via MCP. The command runs directly inside the project container using the pre-installed Node.js.
|
||||||
|
|
||||||
|
#### Example 2: GitHub Server (Stdio, Manual)
|
||||||
|
|
||||||
|
Another npx-based server, with an environment variable for authentication.
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| **Docker Image** | *(empty)* |
|
||||||
|
| **Transport** | Stdio |
|
||||||
|
| **Command** | `npx` |
|
||||||
|
| **Arguments** | `-y @modelcontextprotocol/server-github` |
|
||||||
|
| **Environment Variables** | `GITHUB_PERSONAL_ACCESS_TOKEN` = `ghp_your_token` |
|
||||||
|
|
||||||
|
#### Example 3: Custom MCP Server (HTTP, Docker)
|
||||||
|
|
||||||
|
An MCP server packaged as a Docker image that exposes an HTTP endpoint.
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| **Docker Image** | `myregistry/my-mcp-server:latest` |
|
||||||
|
| **Transport** | HTTP |
|
||||||
|
| **Container Port** | `8080` |
|
||||||
|
| **Environment Variables** | `API_KEY` = `your_key` |
|
||||||
|
|
||||||
|
Triple-C will:
|
||||||
|
1. Pull the image automatically if not present
|
||||||
|
2. Start the container on the project's bridge network
|
||||||
|
3. Configure Claude Code to reach it at `http://triple-c-mcp-{id}:8080/mcp`
|
||||||
|
|
||||||
|
The hostname is the MCP container's name on the Docker network — **not** `localhost`.
|
||||||
|
|
||||||
|
#### Example 4: Database Server (Stdio, Docker)
|
||||||
|
|
||||||
|
An MCP server that needs its own runtime environment, communicating over stdio.
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| **Docker Image** | `mcp/postgres-server:latest` |
|
||||||
|
| **Transport** | Stdio |
|
||||||
|
| **Command** | `node` |
|
||||||
|
| **Arguments** | `dist/index.js` |
|
||||||
|
| **Environment Variables** | `DATABASE_URL` = `postgresql://user:pass@host:5432/db` |
|
||||||
|
|
||||||
|
Triple-C will:
|
||||||
|
1. Pull the image and start it on the project network
|
||||||
|
2. Configure Claude Code to communicate via `docker exec -i triple-c-mcp-{id} node dist/index.js`
|
||||||
|
3. Automatically enable Docker socket access on the project container (required for `docker exec`)
|
||||||
|
|
||||||
|
### Enabling MCP Servers Per-Project
|
||||||
|
|
||||||
|
In a project's configuration panel (click **Config**), the **MCP Servers** section shows checkboxes for all globally defined servers. Toggle each server on or off for that project. Changes take effect on the next container start.
|
||||||
|
|
||||||
|
### How Docker-Based MCP Works
|
||||||
|
|
||||||
|
When a project with Docker-based MCP servers starts:
|
||||||
|
|
||||||
|
1. Missing Docker images are **automatically pulled** (progress shown in the progress modal)
|
||||||
|
2. A dedicated **bridge network** is created for the project (`triple-c-net-{projectId}`)
|
||||||
|
3. Each enabled Docker MCP server gets its own container on that network
|
||||||
|
4. The main project container is connected to the same network
|
||||||
|
5. MCP server configuration is written to `~/.claude.json` inside the container
|
||||||
|
|
||||||
|
**Networking**: Docker-based MCP containers are reached by their container name as a hostname (e.g., `triple-c-mcp-{serverId}`), not by `localhost`. Docker DNS resolves these names automatically on the shared bridge network.
|
||||||
|
|
||||||
|
**Stdio + Docker**: The project container uses `docker exec` to communicate with the MCP container over stdin/stdout. This automatically enables Docker socket access on the project container.
|
||||||
|
|
||||||
|
**HTTP + Docker**: The project container connects to the MCP container's HTTP endpoint using the container hostname and port (e.g., `http://triple-c-mcp-{serverId}:3000/mcp`).
|
||||||
|
|
||||||
|
**Manual (no Docker image)**: Stdio commands run directly inside the project container. HTTP URLs connect to wherever you point them (could be an external service or something running on the host).
|
||||||
|
|
||||||
|
### Configuration Change Detection
|
||||||
|
|
||||||
|
MCP server configuration is tracked via SHA-256 fingerprints stored as Docker labels. If you add, remove, or modify MCP servers for a project, the container is automatically recreated on the next start to apply the new configuration. The container filesystem is snapshotted first, so installed packages are preserved.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## AWS Bedrock Configuration
|
## AWS Bedrock Configuration
|
||||||
|
|
||||||
To use Claude via AWS Bedrock instead of Anthropic's API, switch the auth mode to **Bedrock** on the project card.
|
To use Claude via AWS Bedrock instead of Anthropic's API, switch the auth mode to **Bedrock** on the project card.
|
||||||
@@ -264,7 +409,11 @@ When an update is available, a pulsing **Update** button appears in the top bar.
|
|||||||
|
|
||||||
### Multiple Sessions
|
### Multiple Sessions
|
||||||
|
|
||||||
You can open multiple terminal sessions (even for the same project). Each session gets its own tab in the top bar. Click a tab to switch, or click the **x** on a tab to close it.
|
You can open multiple terminal sessions (even for the same project). Each session gets its own tab in the top bar. Click a tab to switch, or click the **x** on a tab to close it. Tabs show the project name, with a "(bash)" suffix for shell sessions.
|
||||||
|
|
||||||
|
### Bash Shell Sessions
|
||||||
|
|
||||||
|
In addition to Claude Code terminals, you can open a plain **bash login shell** in any running container by clicking the **Shell** button. This is useful for manual inspection, package installation, debugging, or running commands that don't need Claude Code.
|
||||||
|
|
||||||
### URL Detection
|
### URL Detection
|
||||||
|
|
||||||
@@ -272,9 +421,28 @@ When Claude Code prints a long URL (e.g., during `claude login`), Triple-C detec
|
|||||||
|
|
||||||
Shorter URLs in terminal output are also clickable directly.
|
Shorter URLs in terminal output are also clickable directly.
|
||||||
|
|
||||||
|
### Clipboard Support (OSC 52)
|
||||||
|
|
||||||
|
Programs inside the container can copy text to your host clipboard. When a container program uses `xclip`, `xsel`, or `pbcopy`, the text is transparently forwarded to your host clipboard via OSC 52 escape sequences. No additional configuration is required — this works out of the box.
|
||||||
|
|
||||||
### Image Paste
|
### Image Paste
|
||||||
|
|
||||||
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container and the file path is injected into the terminal input so Claude Code can reference it.
|
You can paste images from your clipboard into the terminal (Ctrl+V / Cmd+V). The image is uploaded to the container as `/tmp/clipboard_<timestamp>.png` and the file path is injected into the terminal input so Claude Code can reference it. A toast notification confirms the upload.
|
||||||
|
|
||||||
|
### Jump to Current
|
||||||
|
|
||||||
|
When you scroll up in the terminal to review previous output, a **Jump to Current** button appears in the bottom-right corner. Click it to scroll back to the latest output.
|
||||||
|
|
||||||
|
### File Manager
|
||||||
|
|
||||||
|
Click the **Files** button on a running project to open the file manager modal. You can:
|
||||||
|
|
||||||
|
- **Browse** the container filesystem starting from `/workspace`, with breadcrumb navigation
|
||||||
|
- **Download** any file to your host machine via the download button on each file entry
|
||||||
|
- **Upload** files from your host into the current container directory
|
||||||
|
- **Refresh** the directory listing at any time
|
||||||
|
|
||||||
|
The file manager shows file names, sizes, and modification dates.
|
||||||
|
|
||||||
### Terminal Rendering
|
### Terminal Rendering
|
||||||
|
|
||||||
@@ -356,6 +524,8 @@ The sandbox container (Ubuntu 24.04) comes pre-installed with:
|
|||||||
| build-essential | — | C/C++ compiler toolchain |
|
| build-essential | — | C/C++ compiler toolchain |
|
||||||
| openssh-client | — | SSH for git and remote access |
|
| openssh-client | — | SSH for git and remote access |
|
||||||
|
|
||||||
|
The container also includes **clipboard shims** (`xclip`, `xsel`, `pbcopy`) that forward copy operations to the host via OSC 52, and an **audio shim** (`rec`, `arecord`) for future voice mode support.
|
||||||
|
|
||||||
You can install additional tools at runtime with `sudo apt install`, `pip install`, `npm install -g`, etc. Installed packages persist across container stops (but not across resets).
|
You can install additional tools at runtime with `sudo apt install`, `pip install`, `npm install -g`, etc. Installed packages persist across container stops (but not across resets).
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -378,7 +548,7 @@ You can install additional tools at runtime with `sudo apt install`, `pip instal
|
|||||||
|
|
||||||
- Check that the Docker image is "Ready" in Settings.
|
- Check that the Docker image is "Ready" in Settings.
|
||||||
- Verify that the mounted folder paths exist on your host.
|
- Verify that the mounted folder paths exist on your host.
|
||||||
- Look at the error message displayed in red below the project card.
|
- Look at the error message displayed in the progress modal.
|
||||||
|
|
||||||
### OAuth Login URL Not Opening
|
### OAuth Login URL Not Opening
|
||||||
|
|
||||||
@@ -394,4 +564,10 @@ You can install additional tools at runtime with `sudo apt install`, `pip instal
|
|||||||
### Settings Won't Save
|
### Settings Won't Save
|
||||||
|
|
||||||
- Most project settings can only be changed when the container is **stopped**. Stop the container first, make your changes, then start it again.
|
- Most project settings can only be changed when the container is **stopped**. Stop the container first, make your changes, then start it again.
|
||||||
- Some changes (like toggling Docker access or changing mounted folders) trigger an automatic container recreation on the next start.
|
- Some changes (like toggling Docker access, Mission Control, or changing mounted folders) trigger an automatic container recreation on the next start.
|
||||||
|
|
||||||
|
### MCP Containers Not Starting
|
||||||
|
|
||||||
|
- Ensure the Docker image for the MCP server exists (pull it first if needed).
|
||||||
|
- Check that Docker socket access is available (stdio + Docker MCP servers auto-enable this).
|
||||||
|
- Try resetting the project container to force a clean recreation.
|
||||||
|
|||||||
72
README.md
72
README.md
@@ -27,10 +27,10 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
|
|||||||
### Container Lifecycle
|
### Container Lifecycle
|
||||||
|
|
||||||
1. **Create**: New container created with bind mounts, env vars, and labels
|
1. **Create**: New container created with bind mounts, env vars, and labels
|
||||||
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group
|
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, sets up MCP servers
|
||||||
3. **Terminal**: `docker exec` launches Claude Code with a PTY
|
3. **Terminal**: `docker exec` launches Claude Code (or bash shell) with a PTY
|
||||||
4. **Stop**: Container halted (filesystem persists in named volume)
|
4. **Stop**: Container halted (filesystem persists in named volume); MCP containers stopped
|
||||||
5. **Restart**: Existing container restarted; recreated if settings changed (e.g., Docker access toggled)
|
5. **Restart**: Existing container restarted; recreated if settings changed (detected via SHA-256 fingerprint)
|
||||||
6. **Reset**: Container removed and recreated from scratch (named volume preserved)
|
6. **Reset**: Container removed and recreated from scratch (named volume preserved)
|
||||||
|
|
||||||
### Mounts
|
### Mounts
|
||||||
@@ -41,14 +41,14 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
|
|||||||
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Persists across container recreation |
|
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Persists across container recreation |
|
||||||
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
|
||||||
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
|
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
|
||||||
| `/var/run/docker.sock` | Host Docker socket | Bind | Only if "Allow container spawning" is ON |
|
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON, or auto-enabled by stdio+Docker MCP servers |
|
||||||
|
|
||||||
### Authentication Modes
|
### Authentication Modes
|
||||||
|
|
||||||
Each project can independently use one of:
|
Each project can independently use one of:
|
||||||
|
|
||||||
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets.
|
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets.
|
||||||
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token).
|
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
|
||||||
|
|
||||||
### Container Spawning (Sibling Containers)
|
### Container Spawning (Sibling Containers)
|
||||||
|
|
||||||
@@ -56,6 +56,31 @@ When "Allow container spawning" is enabled per-project, the host Docker socket i
|
|||||||
|
|
||||||
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
|
||||||
|
|
||||||
|
### MCP Server Architecture
|
||||||
|
|
||||||
|
Triple-C supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers as a Beta feature. MCP servers extend Claude Code with external tools and data sources.
|
||||||
|
|
||||||
|
**Modes**: Each MCP server operates in one of four modes based on transport type and whether a Docker image is specified:
|
||||||
|
|
||||||
|
| Mode | Where It Runs | How It Communicates |
|
||||||
|
|------|--------------|---------------------|
|
||||||
|
| Stdio + Manual | Inside the project container | Direct stdin/stdout (e.g., `npx -y @mcp/server`) |
|
||||||
|
| Stdio + Docker | Separate MCP container | `docker exec -i <mcp-container> <command>` from the project container |
|
||||||
|
| HTTP + Manual | External / user-provided | Connects to the URL you specify |
|
||||||
|
| HTTP + Docker | Separate MCP container | `http://<mcp-container>:<port>/mcp` via Docker DNS on a shared bridge network |
|
||||||
|
|
||||||
|
**Key behaviors**:
|
||||||
|
- **Global library**: MCP servers are defined globally in the MCP sidebar tab and stored in `mcp_servers.json`
|
||||||
|
- **Per-project toggles**: Each project enables/disables individual servers via checkboxes
|
||||||
|
- **Auto-pull**: Docker images for MCP servers are pulled automatically if not present when the project starts
|
||||||
|
- **Docker networking**: Docker-based MCP containers run on a per-project bridge network (`triple-c-net-{projectId}`), reachable by container name — not localhost
|
||||||
|
- **Auto-detection**: Config changes are detected via SHA-256 fingerprints and trigger automatic container recreation
|
||||||
|
- **Config injection**: MCP server configuration is written to `~/.claude.json` inside the container via the `MCP_SERVERS_JSON` environment variable, merged by the entrypoint using `jq`
|
||||||
|
|
||||||
|
### Mission Control Integration
|
||||||
|
|
||||||
|
Optional per-project integration with [Flight Control](https://github.com/msieurthenardier/mission-control) — an AI-first development methodology. When enabled, the repo is cloned into the container, skills are installed, and workflow instructions are injected into CLAUDE.md.
|
||||||
|
|
||||||
### Docker Socket Path
|
### Docker Socket Path
|
||||||
|
|
||||||
The socket path is OS-aware:
|
The socket path is OS-aware:
|
||||||
@@ -75,17 +100,32 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
|||||||
| `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts |
|
| `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts |
|
||||||
| `app/src/components/projects/ProjectCard.tsx` | Project config, auth mode, action buttons |
|
| `app/src/components/projects/ProjectCard.tsx` | Project config, auth mode, action buttons |
|
||||||
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
||||||
| `app/src/components/settings/SettingsPanel.tsx` | API key, Docker, AWS settings |
|
| `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) |
|
||||||
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection |
|
| `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress |
|
||||||
| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions |
|
| `app/src/components/mcp/McpPanel.tsx` | MCP server library (global configuration) |
|
||||||
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, inspection |
|
| `app/src/components/mcp/McpServerCard.tsx` | Individual MCP server configuration card |
|
||||||
| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions for terminal interaction |
|
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, and global settings |
|
||||||
|
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, image paste |
|
||||||
|
| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions (claude + bash) |
|
||||||
|
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
|
||||||
|
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
|
||||||
|
| `app/src/hooks/useMcpServers.ts` | MCP server CRUD operations |
|
||||||
|
| `app/src/hooks/useVoice.ts` | Voice mode audio capture (currently hidden) |
|
||||||
|
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, MCP injection, fingerprinting |
|
||||||
|
| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions, file upload/download via tar |
|
||||||
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
|
||||||
|
| `app/src-tauri/src/docker/network.rs` | Per-project bridge networks for MCP containers |
|
||||||
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
|
||||||
| `app/src-tauri/src/models/project.rs` | Project struct (auth mode, Docker access, etc.) |
|
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
|
||||||
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS) |
|
| `app/src-tauri/src/commands/mcp_commands.rs` | MCP server CRUD Tauri commands |
|
||||||
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools |
|
| `app/src-tauri/src/models/project.rs` | Project struct (auth mode, Docker access, MCP servers, Mission Control) |
|
||||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config |
|
| `app/src-tauri/src/models/mcp_server.rs` | MCP server struct (transport, Docker image, env vars) |
|
||||||
|
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, microphone) |
|
||||||
|
| `app/src-tauri/src/storage/mcp_store.rs` | MCP server persistence (JSON with atomic writes) |
|
||||||
|
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims |
|
||||||
|
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, MCP injection, Mission Control setup |
|
||||||
|
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
|
||||||
|
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
|
||||||
|
|
||||||
## CSS / Styling Notes
|
## CSS / Styling Notes
|
||||||
|
|
||||||
@@ -100,4 +140,6 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
|||||||
|
|
||||||
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential
|
**Pre-installed tools**: Claude Code, Node.js 22 LTS + pnpm, Python 3.12 + uv + ruff, Rust (stable), Docker CLI, git + gh, AWS CLI v2, ripgrep, openssh-client, build-essential
|
||||||
|
|
||||||
|
**Shims**: `xclip`/`xsel`/`pbcopy` (OSC 52 clipboard forwarding), `rec`/`arecord` (audio FIFO for voice mode)
|
||||||
|
|
||||||
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
|
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
|
||||||
130
TECHNICAL.md
130
TECHNICAL.md
@@ -154,13 +154,12 @@ The `.claude` configuration directory uses a **named Docker volume** (`triple-c-
|
|||||||
|
|
||||||
### Authentication Modes
|
### Authentication Modes
|
||||||
|
|
||||||
Each project independently chooses one of three authentication methods:
|
Each project independently chooses one of two authentication methods:
|
||||||
|
|
||||||
| Mode | How It Works | When to Use |
|
| Mode | How It Works | When to Use |
|
||||||
|------|-------------|-------------|
|
|------|-------------|-------------|
|
||||||
| **Login (OAuth)** | User runs `claude login` or `/login` inside the terminal. OAuth URL opens in host browser via the web links addon. Token persists in the `.claude` config volume. | Personal use, interactive sessions |
|
| **Anthropic (OAuth)** | User runs `claude login` or `/login` inside the terminal. OAuth URL opens in host browser via URL detection. Token persists in the `.claude` config volume. | Default — personal and team use |
|
||||||
| **API Key** | Key stored in OS keychain, injected as `ANTHROPIC_API_KEY` env var at container creation. | Automated workflows, team-shared keys |
|
| **AWS Bedrock** | Per-project AWS credentials (static keys, profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only. | Enterprise environments using Bedrock |
|
||||||
| **AWS Bedrock** | Per-project AWS credentials (static, profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only. | Enterprise environments using Bedrock |
|
|
||||||
|
|
||||||
### UID/GID Remapping
|
### UID/GID Remapping
|
||||||
|
|
||||||
@@ -213,66 +212,93 @@ The `TerminalView` component works around this with a **URL accumulator**:
|
|||||||
|
|
||||||
```
|
```
|
||||||
triple-c/
|
triple-c/
|
||||||
├── LICENSE # MIT
|
├── README.md # Architecture overview
|
||||||
├── TECHNICAL.md # This document
|
├── TECHNICAL.md # This document
|
||||||
├── Triple-C.md # Project overview
|
├── HOW-TO-USE.md # User guide
|
||||||
|
├── BUILDING.md # Build instructions
|
||||||
|
├── CLAUDE.md # Claude Code instructions
|
||||||
│
|
│
|
||||||
├── container/
|
├── container/
|
||||||
│ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code
|
│ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code
|
||||||
│ └── entrypoint.sh # UID/GID remap, SSH setup, git config
|
│ ├── entrypoint.sh # UID/GID remap, SSH setup, git config, MCP injection
|
||||||
|
│ ├── osc52-clipboard # Clipboard shim (xclip/xsel/pbcopy via OSC 52)
|
||||||
|
│ ├── audio-shim # Audio capture shim (rec/arecord via FIFO)
|
||||||
|
│ ├── triple-c-scheduler # Bash-based cron task system
|
||||||
|
│ └── triple-c-task-runner # Task execution runner for scheduler
|
||||||
│
|
│
|
||||||
└── app/ # Tauri v2 desktop application
|
├── .gitea/
|
||||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
│ └── workflows/
|
||||||
├── vite.config.ts # Vite bundler config
|
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
|
||||||
├── index.html # HTML entry point
|
│ ├── build.yml # Build container image (multi-arch)
|
||||||
|
│ ├── sync-release.yml # Mirror releases to GitHub
|
||||||
|
│ └── backfill-releases.yml # Bulk copy releases to GitHub
|
||||||
|
│
|
||||||
|
└── app/ # Tauri v2 desktop application
|
||||||
|
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||||
|
├── vite.config.ts # Vite bundler config
|
||||||
|
├── index.html # HTML entry point
|
||||||
│
|
│
|
||||||
├── src/ # React frontend
|
├── src/ # React frontend
|
||||||
│ ├── main.tsx # React DOM root
|
│ ├── main.tsx # React DOM root
|
||||||
│ ├── App.tsx # Top-level layout
|
│ ├── App.tsx # Top-level layout
|
||||||
│ ├── index.css # CSS variables, dark theme, scrollbars
|
│ ├── index.css # CSS variables, dark theme, scrollbars
|
||||||
│ ├── store/
|
│ ├── store/
|
||||||
│ │ └── appState.ts # Zustand store (projects, sessions, UI)
|
│ │ └── appState.ts # Zustand store (projects, sessions, MCP, UI)
|
||||||
│ ├── hooks/
|
│ ├── hooks/
|
||||||
│ │ ├── useDocker.ts # Docker status, image build
|
│ │ ├── useDocker.ts # Docker status, image build/pull
|
||||||
│ │ ├── useProjects.ts # Project CRUD operations
|
│ │ ├── useFileManager.ts # File manager operations
|
||||||
│ │ ├── useSettings.ts # API key, app settings
|
│ │ ├── useMcpServers.ts # MCP server CRUD
|
||||||
│ │ └── useTerminal.ts # Terminal I/O, resize, session events
|
│ │ ├── useProjects.ts # Project CRUD operations
|
||||||
|
│ │ ├── useSettings.ts # App settings
|
||||||
|
│ │ ├── useTerminal.ts # Terminal I/O, resize, session events
|
||||||
|
│ │ ├── useUpdates.ts # App update checking
|
||||||
|
│ │ └── useVoice.ts # Voice mode audio capture
|
||||||
│ ├── lib/
|
│ ├── lib/
|
||||||
│ │ ├── types.ts # TypeScript interfaces matching Rust models
|
│ │ ├── types.ts # TypeScript interfaces matching Rust models
|
||||||
│ │ ├── tauri-commands.ts # Typed invoke() wrappers
|
│ │ ├── tauri-commands.ts # Typed invoke() wrappers
|
||||||
│ │ └── constants.ts # App-wide constants
|
│ │ └── constants.ts # App-wide constants
|
||||||
│ └── components/
|
│ └── components/
|
||||||
│ ├── layout/ # Sidebar, TopBar, StatusBar
|
│ ├── layout/ # Sidebar, TopBar, StatusBar
|
||||||
│ ├── projects/ # ProjectList, ProjectCard, AddProjectDialog
|
│ ├── mcp/ # McpPanel, McpServerCard
|
||||||
│ ├── terminal/ # TerminalView (xterm.js), TerminalTabs
|
│ ├── projects/ # ProjectCard, ProjectList, AddProjectDialog,
|
||||||
│ ├── settings/ # ApiKeyInput, DockerSettings, AwsSettings
|
│ │ # FileManagerModal, ContainerProgressModal, modals
|
||||||
│ └── containers/ # SiblingContainers
|
│ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings,
|
||||||
|
│ │ # UpdateDialog
|
||||||
|
│ └── terminal/ # TerminalView (xterm.js), TerminalTabs, UrlToast
|
||||||
│
|
│
|
||||||
└── src-tauri/ # Rust backend
|
└── src-tauri/ # Rust backend
|
||||||
├── Cargo.toml # Rust dependencies
|
├── Cargo.toml # Rust dependencies
|
||||||
├── tauri.conf.json # Tauri app configuration
|
├── tauri.conf.json # Tauri app configuration
|
||||||
├── capabilities/
|
├── capabilities/
|
||||||
│ └── default.json # Tauri v2 permission grants
|
│ └── default.json # Tauri v2 permission grants
|
||||||
└── src/
|
└── src/
|
||||||
├── lib.rs # App builder, plugin + command registration
|
├── lib.rs # App builder, plugin + command registration
|
||||||
├── main.rs # Entry point
|
├── main.rs # Entry point
|
||||||
├── commands/ # Tauri command handlers
|
├── logging.rs # Log configuration
|
||||||
│ ├── docker_commands.rs
|
├── commands/ # Tauri command handlers
|
||||||
│ ├── project_commands.rs
|
│ ├── docker_commands.rs # Docker status, image ops
|
||||||
│ ├── settings_commands.rs
|
│ ├── file_commands.rs # File manager (list/download/upload)
|
||||||
│ └── terminal_commands.rs
|
│ ├── mcp_commands.rs # MCP server CRUD
|
||||||
├── docker/ # Docker API layer
|
│ ├── project_commands.rs # Start/stop/rebuild containers
|
||||||
│ ├── client.rs # bollard singleton connection
|
│ ├── settings_commands.rs # Settings CRUD
|
||||||
│ ├── container.rs # Create, start, stop, remove, inspect
|
│ ├── terminal_commands.rs # Terminal I/O, resize
|
||||||
│ ├── exec.rs # PTY exec sessions with bidirectional streaming
|
│ └── update_commands.rs # App update checking
|
||||||
│ ├── image.rs # Build from embedded Dockerfile, pull from registry
|
├── docker/ # Docker API layer
|
||||||
│ └── sibling.rs # List non-Triple-C containers
|
│ ├── client.rs # bollard singleton connection
|
||||||
├── models/ # Data structures
|
│ ├── container.rs # Create, start, stop, remove, fingerprinting
|
||||||
│ ├── project.rs # Project, AuthMode, BedrockConfig
|
│ ├── exec.rs # PTY exec sessions with bidirectional streaming
|
||||||
│ └── container_config.rs
|
│ ├── image.rs # Build from Dockerfile, pull from registry
|
||||||
└── storage/ # Persistence
|
│ └── network.rs # Per-project bridge networks for MCP
|
||||||
|
├── models/ # Data structures
|
||||||
|
│ ├── project.rs # Project, AuthMode, BedrockConfig
|
||||||
|
│ ├── mcp_server.rs # MCP server configuration
|
||||||
|
│ ├── app_settings.rs # Global settings (image source, AWS, etc.)
|
||||||
|
│ ├── container_config.rs # Image name resolution
|
||||||
|
│ └── update_info.rs # Update metadata
|
||||||
|
└── storage/ # Persistence
|
||||||
├── projects_store.rs # JSON file with atomic writes
|
├── projects_store.rs # JSON file with atomic writes
|
||||||
├── settings_store.rs # App settings
|
├── mcp_store.rs # MCP server persistence
|
||||||
|
├── settings_store.rs # App settings (Tauri plugin-store)
|
||||||
└── secure.rs # OS keychain via keyring
|
└── secure.rs # OS keychain via keyring
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
211
app/src-tauri/src/commands/file_commands.rs
Normal file
211
app/src-tauri/src/commands/file_commands.rs
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
use bollard::container::{DownloadFromContainerOptions, UploadToContainerOptions};
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use serde::Serialize;
|
||||||
|
use tauri::State;
|
||||||
|
|
||||||
|
use crate::docker::client::get_docker;
|
||||||
|
use crate::docker::exec::exec_oneshot;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct FileEntry {
|
||||||
|
pub name: String,
|
||||||
|
pub path: String,
|
||||||
|
pub is_directory: bool,
|
||||||
|
pub size: u64,
|
||||||
|
pub modified: String,
|
||||||
|
pub permissions: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_container_files(
|
||||||
|
project_id: String,
|
||||||
|
path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<FileEntry>, String> {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
let container_id = project
|
||||||
|
.container_id
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "Container not running".to_string())?;
|
||||||
|
|
||||||
|
let cmd = vec![
|
||||||
|
"find".to_string(),
|
||||||
|
path.clone(),
|
||||||
|
"-mindepth".to_string(),
|
||||||
|
"1".to_string(),
|
||||||
|
"-maxdepth".to_string(),
|
||||||
|
"1".to_string(),
|
||||||
|
"-printf".to_string(),
|
||||||
|
"%f\t%y\t%s\t%T@\t%m\n".to_string(),
|
||||||
|
];
|
||||||
|
|
||||||
|
let output = exec_oneshot(container_id, cmd).await?;
|
||||||
|
|
||||||
|
let mut entries: Vec<FileEntry> = output
|
||||||
|
.lines()
|
||||||
|
.filter(|line| !line.trim().is_empty())
|
||||||
|
.filter_map(|line| {
|
||||||
|
let parts: Vec<&str> = line.split('\t').collect();
|
||||||
|
if parts.len() < 5 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let name = parts[0].to_string();
|
||||||
|
let is_directory = parts[1] == "d";
|
||||||
|
let size = parts[2].parse::<u64>().unwrap_or(0);
|
||||||
|
let modified_epoch = parts[3].parse::<f64>().unwrap_or(0.0);
|
||||||
|
let permissions = parts[4].to_string();
|
||||||
|
|
||||||
|
// Convert epoch to ISO-ish string
|
||||||
|
let modified = {
|
||||||
|
let secs = modified_epoch as i64;
|
||||||
|
let dt = chrono::DateTime::from_timestamp(secs, 0)
|
||||||
|
.unwrap_or_default();
|
||||||
|
dt.format("%Y-%m-%d %H:%M:%S").to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let entry_path = if path.ends_with('/') {
|
||||||
|
format!("{}{}", path, name)
|
||||||
|
} else {
|
||||||
|
format!("{}/{}", path, name)
|
||||||
|
};
|
||||||
|
|
||||||
|
Some(FileEntry {
|
||||||
|
name,
|
||||||
|
path: entry_path,
|
||||||
|
is_directory,
|
||||||
|
size,
|
||||||
|
modified,
|
||||||
|
permissions,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Sort: directories first, then alphabetical
|
||||||
|
entries.sort_by(|a, b| {
|
||||||
|
b.is_directory
|
||||||
|
.cmp(&a.is_directory)
|
||||||
|
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn download_container_file(
|
||||||
|
project_id: String,
|
||||||
|
container_path: String,
|
||||||
|
host_path: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
let container_id = project
|
||||||
|
.container_id
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "Container not running".to_string())?;
|
||||||
|
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let mut stream = docker.download_from_container(
|
||||||
|
container_id,
|
||||||
|
Some(DownloadFromContainerOptions {
|
||||||
|
path: container_path.clone(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut tar_bytes = Vec::new();
|
||||||
|
while let Some(chunk) = stream.next().await {
|
||||||
|
let chunk = chunk.map_err(|e| format!("Failed to download file: {}", e))?;
|
||||||
|
tar_bytes.extend_from_slice(&chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract single file from tar archive
|
||||||
|
let mut archive = tar::Archive::new(&tar_bytes[..]);
|
||||||
|
let mut found = false;
|
||||||
|
for entry in archive
|
||||||
|
.entries()
|
||||||
|
.map_err(|e| format!("Failed to read tar entries: {}", e))?
|
||||||
|
{
|
||||||
|
let mut entry = entry.map_err(|e| format!("Failed to read tar entry: {}", e))?;
|
||||||
|
let mut contents = Vec::new();
|
||||||
|
std::io::Read::read_to_end(&mut entry, &mut contents)
|
||||||
|
.map_err(|e| format!("Failed to read file contents: {}", e))?;
|
||||||
|
std::fs::write(&host_path, &contents)
|
||||||
|
.map_err(|e| format!("Failed to write file to host: {}", e))?;
|
||||||
|
found = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return Err("File not found in tar archive".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn upload_file_to_container(
|
||||||
|
project_id: String,
|
||||||
|
host_path: String,
|
||||||
|
container_dir: String,
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let project = state
|
||||||
|
.projects_store
|
||||||
|
.get(&project_id)
|
||||||
|
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||||
|
|
||||||
|
let container_id = project
|
||||||
|
.container_id
|
||||||
|
.as_ref()
|
||||||
|
.ok_or_else(|| "Container not running".to_string())?;
|
||||||
|
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let file_data = std::fs::read(&host_path)
|
||||||
|
.map_err(|e| format!("Failed to read host file: {}", e))?;
|
||||||
|
|
||||||
|
let file_name = std::path::Path::new(&host_path)
|
||||||
|
.file_name()
|
||||||
|
.ok_or_else(|| "Invalid file path".to_string())?
|
||||||
|
.to_string_lossy()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
|
// Build tar archive in memory
|
||||||
|
let mut tar_buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut builder = tar::Builder::new(&mut tar_buf);
|
||||||
|
let mut header = tar::Header::new_gnu();
|
||||||
|
header.set_size(file_data.len() as u64);
|
||||||
|
header.set_mode(0o644);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_data(&mut header, &file_name, &file_data[..])
|
||||||
|
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
|
||||||
|
builder
|
||||||
|
.finish()
|
||||||
|
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
docker
|
||||||
|
.upload_to_container(
|
||||||
|
container_id,
|
||||||
|
Some(UploadToContainerOptions {
|
||||||
|
path: container_dir,
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
tar_buf.into(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod docker_commands;
|
pub mod docker_commands;
|
||||||
|
pub mod file_commands;
|
||||||
pub mod mcp_commands;
|
pub mod mcp_commands;
|
||||||
pub mod project_commands;
|
pub mod project_commands;
|
||||||
pub mod settings_commands;
|
pub mod settings_commands;
|
||||||
|
|||||||
@@ -202,6 +202,28 @@ pub async fn start_project_container(
|
|||||||
|
|
||||||
// Set up Docker network and MCP containers if needed
|
// Set up Docker network and MCP containers if needed
|
||||||
let network_name = if !docker_mcp.is_empty() {
|
let network_name = if !docker_mcp.is_empty() {
|
||||||
|
// Pull any missing MCP Docker images before starting containers
|
||||||
|
for server in &docker_mcp {
|
||||||
|
if let Some(ref image) = server.docker_image {
|
||||||
|
if !docker::image_exists(image).await.unwrap_or(false) {
|
||||||
|
emit_progress(
|
||||||
|
&app_handle,
|
||||||
|
&project_id,
|
||||||
|
&format!("Pulling MCP image for '{}'...", server.name),
|
||||||
|
);
|
||||||
|
let image_clone = image.clone();
|
||||||
|
let app_clone = app_handle.clone();
|
||||||
|
let pid_clone = project_id.clone();
|
||||||
|
let sname = server.name.clone();
|
||||||
|
docker::pull_image(&image_clone, move |msg| {
|
||||||
|
emit_progress(&app_clone, &pid_clone, &format!("[{}] {}", sname, msg));
|
||||||
|
}).await.map_err(|e| {
|
||||||
|
format!("Failed to pull MCP image '{}' for '{}': {}", image, server.name, e)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
emit_progress(&app_handle, &project_id, "Setting up MCP network...");
|
emit_progress(&app_handle, &project_id, "Setting up MCP network...");
|
||||||
let net = docker::ensure_project_network(&project.id).await?;
|
let net = docker::ensure_project_network(&project.id).await?;
|
||||||
emit_progress(&app_handle, &project_id, "Starting MCP containers...");
|
emit_progress(&app_handle, &project_id, "Starting MCP containers...");
|
||||||
@@ -386,6 +408,46 @@ pub async fn rebuild_project_container(
|
|||||||
start_project_container(project_id, app_handle, state).await
|
start_project_container(project_id, app_handle, state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reconcile project statuses against actual Docker container state.
|
||||||
|
/// Called by the frontend after Docker is confirmed available. Projects
|
||||||
|
/// marked as Running whose containers are no longer running get reset
|
||||||
|
/// to Stopped.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn reconcile_project_statuses(
|
||||||
|
state: State<'_, AppState>,
|
||||||
|
) -> Result<Vec<Project>, String> {
|
||||||
|
let projects = state.projects_store.list();
|
||||||
|
|
||||||
|
for project in &projects {
|
||||||
|
if project.status != ProjectStatus::Running && project.status != ProjectStatus::Error {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let is_running = if let Some(ref container_id) = project.container_id {
|
||||||
|
docker::is_container_running(container_id).await.unwrap_or(false)
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_running {
|
||||||
|
log::info!(
|
||||||
|
"Project '{}' ({}) container is still running — keeping Running status",
|
||||||
|
project.name,
|
||||||
|
project.id
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log::info!(
|
||||||
|
"Project '{}' ({}) container is not running — setting to Stopped",
|
||||||
|
project.name,
|
||||||
|
project.id
|
||||||
|
);
|
||||||
|
let _ = state.projects_store.update_status(&project.id, ProjectStatus::Stopped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(state.projects_store.list())
|
||||||
|
}
|
||||||
|
|
||||||
fn default_docker_socket() -> String {
|
fn default_docker_socket() -> String {
|
||||||
if cfg!(target_os = "windows") {
|
if cfg!(target_os = "windows") {
|
||||||
"//./pipe/docker_engine".to_string()
|
"//./pipe/docker_engine".to_string()
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ exec claude --dangerously-skip-permissions
|
|||||||
pub async fn open_terminal_session(
|
pub async fn open_terminal_session(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
session_id: String,
|
session_id: String,
|
||||||
|
session_type: Option<String>,
|
||||||
app_handle: AppHandle,
|
app_handle: AppHandle,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
@@ -86,7 +87,10 @@ pub async fn open_terminal_session(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "Container not running".to_string())?;
|
.ok_or_else(|| "Container not running".to_string())?;
|
||||||
|
|
||||||
let cmd = build_terminal_cmd(&project, &state);
|
let cmd = match session_type.as_deref() {
|
||||||
|
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
|
||||||
|
_ => build_terminal_cmd(&project, &state),
|
||||||
|
};
|
||||||
|
|
||||||
let output_event = format!("terminal-output-{}", session_id);
|
let output_event = format!("terminal-output-{}", session_id);
|
||||||
let exit_event = format!("terminal-exit-{}", session_id);
|
let exit_event = format!("terminal-exit-{}", session_id);
|
||||||
|
|||||||
@@ -40,6 +40,54 @@ After tasks run, check notifications with `triple-c-scheduler notifications` and
|
|||||||
### Timezone
|
### Timezone
|
||||||
Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#;
|
Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#;
|
||||||
|
|
||||||
|
const MISSION_CONTROL_GLOBAL_INSTRUCTIONS: &str = r#"## Mission Control
|
||||||
|
|
||||||
|
The `/workspace/mission-control/` directory contains **Flight Control** — an AI-first development methodology for structured project management. Use it for all project work.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
- **Mission Control is a tool, not a project.** It provides skills and methodology for managing other projects.
|
||||||
|
- All Flight Control skills are installed as personal skills in `~/.claude/skills/` and are automatically available as `/slash-commands`
|
||||||
|
- The methodology docs and project registry live in `/workspace/mission-control/`
|
||||||
|
|
||||||
|
### When to Use
|
||||||
|
|
||||||
|
When working on any project that has a `.flightops/` directory, follow the Flight Control methodology:
|
||||||
|
1. Read the project's `.flightops/ARTIFACTS.md` to understand artifact storage
|
||||||
|
2. Read `.flightops/FLIGHT_OPERATIONS.md` for the implementation workflow
|
||||||
|
3. Use Mission Control skills for planning and execution
|
||||||
|
|
||||||
|
### Available Skills
|
||||||
|
|
||||||
|
| Skill | When to Use |
|
||||||
|
|-------|-------------|
|
||||||
|
| `/init-project` | Setting up a new project for Flight Control |
|
||||||
|
| `/mission` | Defining new work outcomes (days-to-weeks scope) |
|
||||||
|
| `/flight` | Creating technical specs from missions (hours-to-days scope) |
|
||||||
|
| `/leg` | Generating implementation steps from flights (minutes-to-hours scope) |
|
||||||
|
| `/agentic-workflow` | Executing legs with multi-agent workflow (implement, review, commit) |
|
||||||
|
| `/flight-debrief` | Post-flight analysis after a flight lands |
|
||||||
|
| `/mission-debrief` | Post-mission retrospective after completion |
|
||||||
|
| `/daily-briefing` | Cross-project status report |
|
||||||
|
|
||||||
|
### Key Rules
|
||||||
|
|
||||||
|
- **Planning skills produce artifacts only** — never modify source code directly
|
||||||
|
- **Phase gates require human confirmation** — missions before flights, flights before legs
|
||||||
|
- **Legs are immutable once in-flight** — create new ones instead of modifying
|
||||||
|
- **`/agentic-workflow` orchestrates implementation** — it spawns separate Developer and Reviewer agents
|
||||||
|
- **Artifacts live in the target project** — not in mission-control"#;
|
||||||
|
|
||||||
|
const MISSION_CONTROL_PROJECT_INSTRUCTIONS: &str = r#"## Flight Operations
|
||||||
|
|
||||||
|
This project uses [Flight Control](https://github.com/msieurthenardier/mission-control) for structured development.
|
||||||
|
|
||||||
|
**Before any mission/flight/leg work, read these files in order:**
|
||||||
|
1. `.flightops/README.md` — What the flightops directory contains
|
||||||
|
2. `.flightops/FLIGHT_OPERATIONS.md` — **The workflow you MUST follow**
|
||||||
|
3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored
|
||||||
|
4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)"#;
|
||||||
|
|
||||||
/// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
|
/// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
|
||||||
/// instructions, appending port mapping docs, and appending scheduler docs.
|
/// instructions, appending port mapping docs, and appending scheduler docs.
|
||||||
/// Used by both create_container() and container_needs_recreation() to ensure
|
/// Used by both create_container() and container_needs_recreation() to ensure
|
||||||
@@ -48,8 +96,13 @@ fn build_claude_instructions(
|
|||||||
global_instructions: Option<&str>,
|
global_instructions: Option<&str>,
|
||||||
project_instructions: Option<&str>,
|
project_instructions: Option<&str>,
|
||||||
port_mappings: &[PortMapping],
|
port_mappings: &[PortMapping],
|
||||||
|
mission_control_enabled: bool,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
let mut combined = merge_claude_instructions(global_instructions, project_instructions);
|
let mut combined = merge_claude_instructions(
|
||||||
|
global_instructions,
|
||||||
|
project_instructions,
|
||||||
|
mission_control_enabled,
|
||||||
|
);
|
||||||
|
|
||||||
if !port_mappings.is_empty() {
|
if !port_mappings.is_empty() {
|
||||||
let mut port_lines: Vec<String> = Vec::new();
|
let mut port_lines: Vec<String> = Vec::new();
|
||||||
@@ -116,14 +169,37 @@ fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec<EnvVar> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Merge global and per-project Claude instructions into a single string.
|
/// Merge global and per-project Claude instructions into a single string.
|
||||||
|
/// When mission_control_enabled is true, appends Mission Control global
|
||||||
|
/// instructions after global and project instructions after project.
|
||||||
fn merge_claude_instructions(
|
fn merge_claude_instructions(
|
||||||
global_instructions: Option<&str>,
|
global_instructions: Option<&str>,
|
||||||
project_instructions: Option<&str>,
|
project_instructions: Option<&str>,
|
||||||
|
mission_control_enabled: bool,
|
||||||
) -> Option<String> {
|
) -> Option<String> {
|
||||||
match (global_instructions, project_instructions) {
|
// Build the global portion (user global + optional MC global)
|
||||||
|
let global_part = if mission_control_enabled {
|
||||||
|
match global_instructions {
|
||||||
|
Some(g) => Some(format!("{}\n\n{}", g, MISSION_CONTROL_GLOBAL_INSTRUCTIONS)),
|
||||||
|
None => Some(MISSION_CONTROL_GLOBAL_INSTRUCTIONS.to_string()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
global_instructions.map(|g| g.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the project portion (user project + optional MC project)
|
||||||
|
let project_part = if mission_control_enabled {
|
||||||
|
match project_instructions {
|
||||||
|
Some(p) => Some(format!("{}\n\n{}", p, MISSION_CONTROL_PROJECT_INSTRUCTIONS)),
|
||||||
|
None => Some(MISSION_CONTROL_PROJECT_INSTRUCTIONS.to_string()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
project_instructions.map(|p| p.to_string())
|
||||||
|
};
|
||||||
|
|
||||||
|
match (global_part, project_part) {
|
||||||
(Some(g), Some(p)) => Some(format!("{}\n\n{}", g, p)),
|
(Some(g), Some(p)) => Some(format!("{}\n\n{}", g, p)),
|
||||||
(Some(g), None) => Some(g.to_string()),
|
(Some(g), None) => Some(g),
|
||||||
(None, Some(p)) => Some(p.to_string()),
|
(None, Some(p)) => Some(p),
|
||||||
(None, None) => None,
|
(None, None) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -426,11 +502,17 @@ pub async fn create_container(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mission Control env var
|
||||||
|
if project.mission_control_enabled {
|
||||||
|
env_vars.push("MISSION_CONTROL_ENABLED=1".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
// Claude instructions (global + per-project, plus port mapping info + scheduler docs)
|
// Claude instructions (global + per-project, plus port mapping info + scheduler docs)
|
||||||
let combined_instructions = build_claude_instructions(
|
let combined_instructions = build_claude_instructions(
|
||||||
global_claude_instructions,
|
global_claude_instructions,
|
||||||
project.claude_instructions.as_deref(),
|
project.claude_instructions.as_deref(),
|
||||||
&project.port_mappings,
|
&project.port_mappings,
|
||||||
|
project.mission_control_enabled,
|
||||||
);
|
);
|
||||||
|
|
||||||
if let Some(ref instructions) = combined_instructions {
|
if let Some(ref instructions) = combined_instructions {
|
||||||
@@ -567,6 +649,7 @@ pub async fn create_container(
|
|||||||
labels.insert("triple-c.image".to_string(), image_name.to_string());
|
labels.insert("triple-c.image".to_string(), image_name.to_string());
|
||||||
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
|
labels.insert("triple-c.timezone".to_string(), timezone.unwrap_or("").to_string());
|
||||||
labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers));
|
labels.insert("triple-c.mcp-fingerprint".to_string(), compute_mcp_fingerprint(mcp_servers));
|
||||||
|
labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string());
|
||||||
|
|
||||||
let host_config = HostConfig {
|
let host_config = HostConfig {
|
||||||
mounts: Some(mounts),
|
mounts: Some(mounts),
|
||||||
@@ -885,11 +968,20 @@ pub async fn container_needs_recreation(
|
|||||||
return Ok(true);
|
return Ok(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Mission Control ────────────────────────────────────────────────────
|
||||||
|
let expected_mc = project.mission_control_enabled.to_string();
|
||||||
|
let container_mc = get_label("triple-c.mission-control").unwrap_or_else(|| "false".to_string());
|
||||||
|
if container_mc != expected_mc {
|
||||||
|
log::info!("Mission Control mismatch (container={:?}, expected={:?})", container_mc, expected_mc);
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Claude instructions ───────────────────────────────────────────────
|
// ── Claude instructions ───────────────────────────────────────────────
|
||||||
let expected_instructions = build_claude_instructions(
|
let expected_instructions = build_claude_instructions(
|
||||||
global_claude_instructions,
|
global_claude_instructions,
|
||||||
project.claude_instructions.as_deref(),
|
project.claude_instructions.as_deref(),
|
||||||
&project.port_mappings,
|
&project.port_mappings,
|
||||||
|
project.mission_control_enabled,
|
||||||
);
|
);
|
||||||
let container_instructions = get_env("CLAUDE_INSTRUCTIONS");
|
let container_instructions = get_env("CLAUDE_INSTRUCTIONS");
|
||||||
if container_instructions.as_deref() != expected_instructions.as_deref() {
|
if container_instructions.as_deref() != expected_instructions.as_deref() {
|
||||||
@@ -939,6 +1031,16 @@ pub async fn get_container_info(project: &Project) -> Result<Option<ContainerInf
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Check whether a Docker container is currently running.
|
||||||
|
/// Returns false if the container doesn't exist or Docker is unavailable.
|
||||||
|
pub async fn is_container_running(container_id: &str) -> Result<bool, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
match docker.inspect_container(container_id, None).await {
|
||||||
|
Ok(info) => Ok(info.state.and_then(|s| s.running).unwrap_or(false)),
|
||||||
|
Err(_) => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn list_sibling_containers() -> Result<Vec<ContainerSummary>, String> {
|
pub async fn list_sibling_containers() -> Result<Vec<ContainerSummary>, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
|||||||
@@ -277,3 +277,41 @@ impl ExecSessionManager {
|
|||||||
Ok(format!("/tmp/{}", file_name))
|
Ok(format!("/tmp/{}", file_name))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
||||||
|
pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String, String> {
|
||||||
|
let docker = get_docker()?;
|
||||||
|
|
||||||
|
let exec = docker
|
||||||
|
.create_exec(
|
||||||
|
container_id,
|
||||||
|
CreateExecOptions {
|
||||||
|
attach_stdout: Some(true),
|
||||||
|
attach_stderr: Some(true),
|
||||||
|
cmd: Some(cmd),
|
||||||
|
user: Some("claude".to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
||||||
|
|
||||||
|
let result = docker
|
||||||
|
.start_exec(&exec.id, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||||
|
|
||||||
|
match result {
|
||||||
|
StartExecResults::Attached { mut output, .. } => {
|
||||||
|
let mut stdout = String::new();
|
||||||
|
while let Some(msg) = output.next().await {
|
||||||
|
match msg {
|
||||||
|
Ok(data) => stdout.push_str(&String::from_utf8_lossy(&data.into_bytes())),
|
||||||
|
Err(e) => return Err(format!("Exec output error: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(stdout)
|
||||||
|
}
|
||||||
|
StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ pub fn run() {
|
|||||||
commands::project_commands::start_project_container,
|
commands::project_commands::start_project_container,
|
||||||
commands::project_commands::stop_project_container,
|
commands::project_commands::stop_project_container,
|
||||||
commands::project_commands::rebuild_project_container,
|
commands::project_commands::rebuild_project_container,
|
||||||
|
commands::project_commands::reconcile_project_statuses,
|
||||||
// Settings
|
// Settings
|
||||||
commands::settings_commands::get_settings,
|
commands::settings_commands::get_settings,
|
||||||
commands::settings_commands::update_settings,
|
commands::settings_commands::update_settings,
|
||||||
@@ -104,6 +105,10 @@ pub fn run() {
|
|||||||
commands::terminal_commands::start_audio_bridge,
|
commands::terminal_commands::start_audio_bridge,
|
||||||
commands::terminal_commands::send_audio_data,
|
commands::terminal_commands::send_audio_data,
|
||||||
commands::terminal_commands::stop_audio_bridge,
|
commands::terminal_commands::stop_audio_bridge,
|
||||||
|
// Files
|
||||||
|
commands::file_commands::list_container_files,
|
||||||
|
commands::file_commands::download_container_file,
|
||||||
|
commands::file_commands::upload_file_to_container,
|
||||||
// MCP
|
// MCP
|
||||||
commands::mcp_commands::list_mcp_servers,
|
commands::mcp_commands::list_mcp_servers,
|
||||||
commands::mcp_commands::add_mcp_server,
|
commands::mcp_commands::add_mcp_server,
|
||||||
|
|||||||
@@ -34,8 +34,10 @@ pub struct Project {
|
|||||||
pub auth_mode: AuthMode,
|
pub auth_mode: AuthMode,
|
||||||
pub bedrock_config: Option<BedrockConfig>,
|
pub bedrock_config: Option<BedrockConfig>,
|
||||||
pub allow_docker_access: bool,
|
pub allow_docker_access: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mission_control_enabled: bool,
|
||||||
pub ssh_key_path: Option<String>,
|
pub ssh_key_path: Option<String>,
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing, default)]
|
||||||
pub git_token: Option<String>,
|
pub git_token: Option<String>,
|
||||||
pub git_user_name: Option<String>,
|
pub git_user_name: Option<String>,
|
||||||
pub git_user_email: Option<String>,
|
pub git_user_email: Option<String>,
|
||||||
@@ -100,14 +102,14 @@ impl Default for BedrockAuthMethod {
|
|||||||
pub struct BedrockConfig {
|
pub struct BedrockConfig {
|
||||||
pub auth_method: BedrockAuthMethod,
|
pub auth_method: BedrockAuthMethod,
|
||||||
pub aws_region: String,
|
pub aws_region: String,
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing, default)]
|
||||||
pub aws_access_key_id: Option<String>,
|
pub aws_access_key_id: Option<String>,
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing, default)]
|
||||||
pub aws_secret_access_key: Option<String>,
|
pub aws_secret_access_key: Option<String>,
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing, default)]
|
||||||
pub aws_session_token: Option<String>,
|
pub aws_session_token: Option<String>,
|
||||||
pub aws_profile: Option<String>,
|
pub aws_profile: Option<String>,
|
||||||
#[serde(skip_serializing)]
|
#[serde(skip_serializing, default)]
|
||||||
pub aws_bearer_token: Option<String>,
|
pub aws_bearer_token: Option<String>,
|
||||||
pub model_id: Option<String>,
|
pub model_id: Option<String>,
|
||||||
pub disable_prompt_caching: bool,
|
pub disable_prompt_caching: bool,
|
||||||
@@ -125,6 +127,7 @@ impl Project {
|
|||||||
auth_mode: AuthMode::default(),
|
auth_mode: AuthMode::default(),
|
||||||
bedrock_config: None,
|
bedrock_config: None,
|
||||||
allow_docker_access: false,
|
allow_docker_access: false,
|
||||||
|
mission_control_enabled: false,
|
||||||
ssh_key_path: None,
|
ssh_key_path: None,
|
||||||
git_token: None,
|
git_token: None,
|
||||||
git_user_name: None,
|
git_user_name: None,
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ impl ProjectsStore {
|
|||||||
|
|
||||||
// Reconcile stale transient statuses: on a cold app start no Docker
|
// Reconcile stale transient statuses: on a cold app start no Docker
|
||||||
// operations can be in flight, so Starting/Stopping are always stale.
|
// operations can be in flight, so Starting/Stopping are always stale.
|
||||||
|
// Running/Error are left as-is and reconciled against Docker later
|
||||||
|
// via the reconcile_project_statuses command.
|
||||||
let mut projects = projects;
|
let mut projects = projects;
|
||||||
let mut needs_save = needs_save;
|
let mut needs_save = needs_save;
|
||||||
for p in projects.iter_mut() {
|
for p in projects.iter_mut() {
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useProjects } from "./hooks/useProjects";
|
|||||||
import { useMcpServers } from "./hooks/useMcpServers";
|
import { useMcpServers } from "./hooks/useMcpServers";
|
||||||
import { useUpdates } from "./hooks/useUpdates";
|
import { useUpdates } from "./hooks/useUpdates";
|
||||||
import { useAppState } from "./store/appState";
|
import { useAppState } from "./store/appState";
|
||||||
|
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { checkDocker, checkImage, startDockerPolling } = useDocker();
|
const { checkDocker, checkImage, startDockerPolling } = useDocker();
|
||||||
@@ -17,8 +18,8 @@ export default function App() {
|
|||||||
const { refresh } = useProjects();
|
const { refresh } = useProjects();
|
||||||
const { refresh: refreshMcp } = useMcpServers();
|
const { refresh: refreshMcp } = useMcpServers();
|
||||||
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
||||||
const { sessions, activeSessionId } = useAppState(
|
const { sessions, activeSessionId, setProjects } = useAppState(
|
||||||
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId }))
|
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects }))
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initialize on mount
|
// Initialize on mount
|
||||||
@@ -28,6 +29,14 @@ export default function App() {
|
|||||||
checkDocker().then((available) => {
|
checkDocker().then((available) => {
|
||||||
if (available) {
|
if (available) {
|
||||||
checkImage();
|
checkImage();
|
||||||
|
// Reconcile project statuses against actual Docker container state,
|
||||||
|
// then refresh the project list so the UI reflects reality.
|
||||||
|
reconcileProjectStatuses().then((projects) => {
|
||||||
|
setProjects(projects);
|
||||||
|
}).catch(() => {
|
||||||
|
// If reconciliation fails (e.g. Docker hiccup), just load from store
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
stopPolling = startDockerPolling();
|
stopPolling = startDockerPolling();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
|
|||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
|
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
|
||||||
Set a Docker image to run this MCP server as a container. Leave empty for manual mode.
|
Set a Docker image to run this MCP server in its own container. Leave empty to run commands inside the project container. Images are pulled automatically if not present.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -171,6 +171,14 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mode description */}
|
||||||
|
<p className="text-xs text-[var(--text-secondary)] opacity-60">
|
||||||
|
{transportType === "stdio" && isDocker && "Runs via docker exec in a separate MCP container."}
|
||||||
|
{transportType === "stdio" && !isDocker && "Runs inside the project container (e.g. npx commands)."}
|
||||||
|
{transportType === "http" && isDocker && "Runs in a separate container, reached by hostname on the project network."}
|
||||||
|
{transportType === "http" && !isDocker && "Connects to an MCP server at the URL you specify."}
|
||||||
|
</p>
|
||||||
|
|
||||||
{/* Container Port (HTTP+Docker only) */}
|
{/* Container Port (HTTP+Docker only) */}
|
||||||
{transportType === "http" && isDocker && (
|
{transportType === "http" && isDocker && (
|
||||||
<div>
|
<div>
|
||||||
@@ -183,7 +191,7 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
|
|||||||
className={inputCls}
|
className={inputCls}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
|
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
|
||||||
Port inside the MCP container (default: 3000)
|
Port the MCP server listens on inside its container. The URL is auto-generated as http://<container>:<port>/mcp on the project network.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
55
app/src/components/projects/ConfirmRemoveModal.tsx
Normal file
55
app/src/components/projects/ConfirmRemoveModal.tsx
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectName: string;
|
||||||
|
onConfirm: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }: Props) {
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onCancel();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [onCancel]);
|
||||||
|
|
||||||
|
const handleOverlayClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === overlayRef.current) onCancel();
|
||||||
|
},
|
||||||
|
[onCancel],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[24rem] shadow-xl">
|
||||||
|
<h2 className="text-lg font-semibold mb-3">Remove Project</h2>
|
||||||
|
<p className="text-sm text-[var(--text-secondary)] mb-5">
|
||||||
|
Are you sure you want to remove <strong className="text-[var(--text-primary)]">{projectName}</strong>? This will delete the container, config volume, and stored credentials.
|
||||||
|
</p>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onCancel}
|
||||||
|
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onConfirm}
|
||||||
|
className="px-4 py-2 text-sm text-white bg-[var(--error)] hover:opacity-80 rounded transition-colors"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
197
app/src/components/projects/FileManagerModal.tsx
Normal file
197
app/src/components/projects/FileManagerModal.tsx
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
import { useFileManager } from "../../hooks/useFileManager";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
projectId: string;
|
||||||
|
projectName: string;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileManagerModal({ projectId, projectName, onClose }: Props) {
|
||||||
|
const {
|
||||||
|
currentPath,
|
||||||
|
entries,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
navigate,
|
||||||
|
goUp,
|
||||||
|
refresh,
|
||||||
|
downloadFile,
|
||||||
|
uploadFile,
|
||||||
|
} = useFileManager(projectId);
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Load initial directory
|
||||||
|
useEffect(() => {
|
||||||
|
navigate("/workspace");
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
|
const handleOverlayClick = useCallback(
|
||||||
|
(e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
|
if (e.target === overlayRef.current) onClose();
|
||||||
|
},
|
||||||
|
[onClose],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build breadcrumbs from current path
|
||||||
|
const breadcrumbs = currentPath === "/"
|
||||||
|
? [{ label: "/", path: "/" }]
|
||||||
|
: currentPath.split("/").reduce<{ label: string; path: string }[]>((acc, part, i) => {
|
||||||
|
if (i === 0) {
|
||||||
|
acc.push({ label: "/", path: "/" });
|
||||||
|
} else if (part) {
|
||||||
|
const parentPath = acc[acc.length - 1].path;
|
||||||
|
const fullPath = parentPath === "/" ? `/${part}` : `${parentPath}/${part}`;
|
||||||
|
acc.push({ label: part, path: fullPath });
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={overlayRef}
|
||||||
|
onClick={handleOverlayClick}
|
||||||
|
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
|
||||||
|
>
|
||||||
|
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg shadow-xl w-[36rem] max-h-[80vh] flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-color)]">
|
||||||
|
<h2 className="text-sm font-semibold">Files — {projectName}</h2>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Path bar */}
|
||||||
|
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
|
||||||
|
{breadcrumbs.map((crumb, i) => (
|
||||||
|
<span key={crumb.path} className="flex items-center gap-1">
|
||||||
|
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
|
||||||
|
<button
|
||||||
|
onClick={() => navigate(crumb.path)}
|
||||||
|
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{crumb.label}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<button
|
||||||
|
onClick={refresh}
|
||||||
|
disabled={loading}
|
||||||
|
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50 px-1"
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
↻
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto min-h-0">
|
||||||
|
{error && (
|
||||||
|
<div className="px-4 py-2 text-xs text-[var(--error)]">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && entries.length === 0 ? (
|
||||||
|
<div className="px-4 py-8 text-center text-xs text-[var(--text-secondary)]">
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-xs">
|
||||||
|
<tbody>
|
||||||
|
{/* Go up entry */}
|
||||||
|
{currentPath !== "/" && (
|
||||||
|
<tr
|
||||||
|
onClick={() => goUp()}
|
||||||
|
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-4 py-1.5 text-[var(--text-primary)]">..</td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{entries.map((entry) => (
|
||||||
|
<tr
|
||||||
|
key={entry.name}
|
||||||
|
onClick={() => entry.is_directory && navigate(entry.path)}
|
||||||
|
className={`${
|
||||||
|
entry.is_directory ? "cursor-pointer" : ""
|
||||||
|
} hover:bg-[var(--bg-tertiary)] transition-colors`}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-1.5">
|
||||||
|
<span className={entry.is_directory ? "text-[var(--accent)]" : "text-[var(--text-primary)]"}>
|
||||||
|
{entry.is_directory ? "📁 " : ""}{entry.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap">
|
||||||
|
{!entry.is_directory && formatSize(entry.size)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
|
||||||
|
{entry.modified}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 text-right">
|
||||||
|
{!entry.is_directory && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
downloadFile(entry);
|
||||||
|
}}
|
||||||
|
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors px-1"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{entries.length === 0 && !loading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-4 py-8 text-center text-[var(--text-secondary)]">
|
||||||
|
Empty directory
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-color)]">
|
||||||
|
<button
|
||||||
|
onClick={uploadFile}
|
||||||
|
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
|
||||||
|
>
|
||||||
|
Upload file
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import EnvVarsModal from "./EnvVarsModal";
|
|||||||
import PortMappingsModal from "./PortMappingsModal";
|
import PortMappingsModal from "./PortMappingsModal";
|
||||||
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||||
import ContainerProgressModal from "./ContainerProgressModal";
|
import ContainerProgressModal from "./ContainerProgressModal";
|
||||||
|
import FileManagerModal from "./FileManagerModal";
|
||||||
|
import ConfirmRemoveModal from "./ConfirmRemoveModal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
project: Project;
|
project: Project;
|
||||||
@@ -27,9 +29,13 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||||
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
||||||
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
||||||
|
const [showFileManager, setShowFileManager] = useState(false);
|
||||||
const [progressMsg, setProgressMsg] = useState<string | null>(null);
|
const [progressMsg, setProgressMsg] = useState<string | null>(null);
|
||||||
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
||||||
const [operationCompleted, setOperationCompleted] = useState(false);
|
const [operationCompleted, setOperationCompleted] = useState(false);
|
||||||
|
const [showRemoveModal, setShowRemoveModal] = useState(false);
|
||||||
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
|
const [editName, setEditName] = useState(project.name);
|
||||||
const isSelected = selectedProjectId === project.id;
|
const isSelected = selectedProjectId === project.id;
|
||||||
const isStopped = project.status === "stopped" || project.status === "error";
|
const isStopped = project.status === "stopped" || project.status === "error";
|
||||||
|
|
||||||
@@ -54,6 +60,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
|
|
||||||
// Sync local state when project prop changes (e.g., after save or external update)
|
// Sync local state when project prop changes (e.g., after save or external update)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
setEditName(project.name);
|
||||||
setPaths(project.paths ?? []);
|
setPaths(project.paths ?? []);
|
||||||
setSshKeyPath(project.ssh_key_path ?? "");
|
setSshKeyPath(project.ssh_key_path ?? "");
|
||||||
setGitName(project.git_user_name ?? "");
|
setGitName(project.git_user_name ?? "");
|
||||||
@@ -135,6 +142,14 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenBashShell = async () => {
|
||||||
|
try {
|
||||||
|
await openTerminal(project.id, project.name, "bash");
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleForceStop = async () => {
|
const handleForceStop = async () => {
|
||||||
try {
|
try {
|
||||||
await stop(project.id);
|
await stop(project.id);
|
||||||
@@ -309,7 +324,41 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} />
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${statusColor}`} />
|
||||||
<span className="text-sm font-medium truncate flex-1">{project.name}</span>
|
{isEditingName ? (
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={editName}
|
||||||
|
onChange={(e) => setEditName(e.target.value)}
|
||||||
|
onBlur={async () => {
|
||||||
|
setIsEditingName(false);
|
||||||
|
const trimmed = editName.trim();
|
||||||
|
if (trimmed && trimmed !== project.name) {
|
||||||
|
try {
|
||||||
|
await update({ ...project, name: trimmed });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to rename project:", err);
|
||||||
|
setEditName(project.name);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setEditName(project.name);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
|
if (e.key === "Escape") { setEditName(project.name); setIsEditingName(false); }
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-sm font-medium flex-1 min-w-0 px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded text-[var(--text-primary)] focus:outline-none"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="text-sm font-medium truncate flex-1 cursor-text"
|
||||||
|
title="Double-click to rename"
|
||||||
|
onDoubleClick={(e) => { e.stopPropagation(); setIsEditingName(true); }}
|
||||||
|
>
|
||||||
|
{project.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 ml-4 space-y-0.5">
|
<div className="mt-0.5 ml-4 space-y-0.5">
|
||||||
{project.paths.map((pp, i) => (
|
{project.paths.map((pp, i) => (
|
||||||
@@ -371,6 +420,8 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
<>
|
<>
|
||||||
<ActionButton onClick={handleStop} disabled={loading} label="Stop" />
|
<ActionButton onClick={handleStop} disabled={loading} label="Stop" />
|
||||||
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
||||||
|
<ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" />
|
||||||
|
<ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
@@ -386,11 +437,7 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
label={showConfig ? "Hide" : "Config"}
|
label={showConfig ? "Hide" : "Config"}
|
||||||
/>
|
/>
|
||||||
<ActionButton
|
<ActionButton
|
||||||
onClick={async () => {
|
onClick={() => setShowRemoveModal(true)}
|
||||||
if (confirm(`Remove project "${project.name}"?`)) {
|
|
||||||
await remove(project.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
label="Remove"
|
label="Remove"
|
||||||
danger
|
danger
|
||||||
@@ -576,6 +623,28 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mission Control toggle */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-[var(--text-secondary)]">Mission Control</label>
|
||||||
|
<button
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
await update({ ...project, mission_control_enabled: !project.mission_control_enabled });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to update Mission Control setting:", err);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!isStopped}
|
||||||
|
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
|
||||||
|
project.mission_control_enabled
|
||||||
|
? "bg-[var(--success)] text-white"
|
||||||
|
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{project.mission_control_enabled ? "ON" : "OFF"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Environment Variables */}
|
{/* Environment Variables */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs text-[var(--text-secondary)]">
|
<label className="text-xs text-[var(--text-secondary)]">
|
||||||
@@ -827,6 +896,25 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showFileManager && (
|
||||||
|
<FileManagerModal
|
||||||
|
projectId={project.id}
|
||||||
|
projectName={project.name}
|
||||||
|
onClose={() => setShowFileManager(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRemoveModal && (
|
||||||
|
<ConfirmRemoveModal
|
||||||
|
projectName={project.name}
|
||||||
|
onConfirm={async () => {
|
||||||
|
setShowRemoveModal(false);
|
||||||
|
await remove(project.id);
|
||||||
|
}}
|
||||||
|
onCancel={() => setShowRemoveModal(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeOperation && (
|
{activeOperation && (
|
||||||
<ContainerProgressModal
|
<ContainerProgressModal
|
||||||
projectName={project.name}
|
projectName={project.name}
|
||||||
@@ -869,3 +957,4 @@ function ActionButton({
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useState, useEffect } from "react";
|
|||||||
import ApiKeyInput from "./ApiKeyInput";
|
import ApiKeyInput from "./ApiKeyInput";
|
||||||
import DockerSettings from "./DockerSettings";
|
import DockerSettings from "./DockerSettings";
|
||||||
import AwsSettings from "./AwsSettings";
|
import AwsSettings from "./AwsSettings";
|
||||||
import MicrophoneSettings from "./MicrophoneSettings";
|
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
import { useSettings } from "../../hooks/useSettings";
|
||||||
import { useUpdates } from "../../hooks/useUpdates";
|
import { useUpdates } from "../../hooks/useUpdates";
|
||||||
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
||||||
@@ -60,8 +59,6 @@ export default function SettingsPanel() {
|
|||||||
<DockerSettings />
|
<DockerSettings />
|
||||||
<AwsSettings />
|
<AwsSettings />
|
||||||
|
|
||||||
<MicrophoneSettings />
|
|
||||||
|
|
||||||
{/* Container Timezone */}
|
{/* Container Timezone */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Container Timezone</label>
|
<label className="block text-sm font-medium mb-1">Container Timezone</label>
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ export default function TerminalTabs() {
|
|||||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="truncate max-w-[120px]">{session.projectName}</span>
|
<span className="truncate max-w-[120px]">
|
||||||
|
{session.projectName}{session.sessionType === "bash" ? " (bash)" : ""}
|
||||||
|
</span>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
|
|||||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
import { useSettings } from "../../hooks/useSettings";
|
|
||||||
import { useVoice } from "../../hooks/useVoice";
|
|
||||||
import { UrlDetector } from "../../lib/urlDetector";
|
import { UrlDetector } from "../../lib/urlDetector";
|
||||||
import UrlToast from "./UrlToast";
|
import UrlToast from "./UrlToast";
|
||||||
|
|
||||||
@@ -24,9 +22,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const webglRef = useRef<WebglAddon | null>(null);
|
const webglRef = useRef<WebglAddon | null>(null);
|
||||||
const detectorRef = useRef<UrlDetector | null>(null);
|
const detectorRef = useRef<UrlDetector | null>(null);
|
||||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||||
const { appSettings } = useSettings();
|
|
||||||
|
|
||||||
const voice = useVoice(sessionId, appSettings?.default_microphone);
|
|
||||||
|
|
||||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||||
@@ -205,7 +200,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||||||
webglRef.current = null;
|
webglRef.current = null;
|
||||||
term.dispose();
|
term.dispose();
|
||||||
voice.stop();
|
|
||||||
};
|
};
|
||||||
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
@@ -290,32 +284,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
{imagePasteMsg}
|
{imagePasteMsg}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<button
|
|
||||||
onClick={voice.toggle}
|
|
||||||
title={
|
|
||||||
voice.state === "active"
|
|
||||||
? "Voice active — click to stop"
|
|
||||||
: voice.error
|
|
||||||
? `Voice error: ${voice.error}`
|
|
||||||
: "Enable voice input for /voice mode"
|
|
||||||
}
|
|
||||||
className={`absolute bottom-4 left-4 z-50 px-3 py-1.5 rounded-md text-xs font-medium border shadow-lg transition-colors cursor-pointer ${
|
|
||||||
voice.state === "active"
|
|
||||||
? "bg-[#1a3a2a] text-[#3fb950] border-[#238636] hover:bg-[#243b2a]"
|
|
||||||
: voice.state === "starting"
|
|
||||||
? "bg-[#1f2937] text-[#d29922] border-[#30363d] opacity-75"
|
|
||||||
: voice.state === "error"
|
|
||||||
? "bg-[#3a1a1a] text-[#ff7b72] border-[#da3633] hover:bg-[#4a2020]"
|
|
||||||
: "bg-[#1f2937] text-[#b1bac4] border-[#30363d] hover:bg-[#2d3748] hover:text-[#e6edf3]"
|
|
||||||
}`}
|
|
||||||
disabled={voice.state === "starting"}
|
|
||||||
>
|
|
||||||
{voice.state === "active"
|
|
||||||
? "Mic On"
|
|
||||||
: voice.state === "starting"
|
|
||||||
? "Mic..."
|
|
||||||
: "Mic Off"}
|
|
||||||
</button>
|
|
||||||
{!isAtBottom && (
|
{!isAtBottom && (
|
||||||
<button
|
<button
|
||||||
onClick={handleScrollToBottom}
|
onClick={handleScrollToBottom}
|
||||||
|
|||||||
74
app/src/hooks/useFileManager.ts
Normal file
74
app/src/hooks/useFileManager.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { save, open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||||
|
import type { FileEntry } from "../lib/types";
|
||||||
|
import * as commands from "../lib/tauri-commands";
|
||||||
|
|
||||||
|
export function useFileManager(projectId: string) {
|
||||||
|
const [currentPath, setCurrentPath] = useState("/workspace");
|
||||||
|
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const navigate = useCallback(
|
||||||
|
async (path: string) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const result = await commands.listContainerFiles(projectId, path);
|
||||||
|
setEntries(result);
|
||||||
|
setCurrentPath(path);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goUp = useCallback(() => {
|
||||||
|
if (currentPath === "/") return;
|
||||||
|
const parent = currentPath.replace(/\/[^/]+$/, "") || "/";
|
||||||
|
navigate(parent);
|
||||||
|
}, [currentPath, navigate]);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
navigate(currentPath);
|
||||||
|
}, [currentPath, navigate]);
|
||||||
|
|
||||||
|
const downloadFile = useCallback(
|
||||||
|
async (entry: FileEntry) => {
|
||||||
|
try {
|
||||||
|
const hostPath = await save({ defaultPath: entry.name });
|
||||||
|
if (!hostPath) return;
|
||||||
|
await commands.downloadContainerFile(projectId, entry.path, hostPath);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[projectId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const uploadFile = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const selected = await openDialog({ multiple: false, directory: false });
|
||||||
|
if (!selected) return;
|
||||||
|
await commands.uploadFileToContainer(projectId, selected as string, currentPath);
|
||||||
|
await navigate(currentPath);
|
||||||
|
} catch (e) {
|
||||||
|
setError(String(e));
|
||||||
|
}
|
||||||
|
}, [projectId, currentPath, navigate]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentPath,
|
||||||
|
entries,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
navigate,
|
||||||
|
goUp,
|
||||||
|
refresh,
|
||||||
|
downloadFile,
|
||||||
|
uploadFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,10 +17,10 @@ export function useTerminal() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const open = useCallback(
|
const open = useCallback(
|
||||||
async (projectId: string, projectName: string) => {
|
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude") => {
|
||||||
const sessionId = crypto.randomUUID();
|
const sessionId = crypto.randomUUID();
|
||||||
await commands.openTerminalSession(projectId, sessionId);
|
await commands.openTerminalSession(projectId, sessionId, sessionType);
|
||||||
addSession({ id: sessionId, projectId, projectName });
|
addSession({ id: sessionId, projectId, projectName, sessionType });
|
||||||
return sessionId;
|
return sessionId;
|
||||||
},
|
},
|
||||||
[addSession],
|
[addSession],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, McpServer } from "./types";
|
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, McpServer, FileEntry } from "./types";
|
||||||
|
|
||||||
// Docker
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -24,6 +24,8 @@ export const stopProjectContainer = (projectId: string) =>
|
|||||||
invoke<void>("stop_project_container", { projectId });
|
invoke<void>("stop_project_container", { projectId });
|
||||||
export const rebuildProjectContainer = (projectId: string) =>
|
export const rebuildProjectContainer = (projectId: string) =>
|
||||||
invoke<Project>("rebuild_project_container", { projectId });
|
invoke<Project>("rebuild_project_container", { projectId });
|
||||||
|
export const reconcileProjectStatuses = () =>
|
||||||
|
invoke<Project[]>("reconcile_project_statuses");
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
export const getSettings = () => invoke<AppSettings>("get_settings");
|
export const getSettings = () => invoke<AppSettings>("get_settings");
|
||||||
@@ -39,8 +41,8 @@ export const detectHostTimezone = () =>
|
|||||||
invoke<string>("detect_host_timezone");
|
invoke<string>("detect_host_timezone");
|
||||||
|
|
||||||
// Terminal
|
// Terminal
|
||||||
export const openTerminalSession = (projectId: string, sessionId: string) =>
|
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string) =>
|
||||||
invoke<void>("open_terminal_session", { projectId, sessionId });
|
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType });
|
||||||
export const terminalInput = (sessionId: string, data: number[]) =>
|
export const terminalInput = (sessionId: string, data: number[]) =>
|
||||||
invoke<void>("terminal_input", { sessionId, data });
|
invoke<void>("terminal_input", { sessionId, data });
|
||||||
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
||||||
@@ -65,6 +67,14 @@ export const updateMcpServer = (server: McpServer) =>
|
|||||||
export const removeMcpServer = (serverId: string) =>
|
export const removeMcpServer = (serverId: string) =>
|
||||||
invoke<void>("remove_mcp_server", { serverId });
|
invoke<void>("remove_mcp_server", { serverId });
|
||||||
|
|
||||||
|
// Files
|
||||||
|
export const listContainerFiles = (projectId: string, path: string) =>
|
||||||
|
invoke<FileEntry[]>("list_container_files", { projectId, path });
|
||||||
|
export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) =>
|
||||||
|
invoke<void>("download_container_file", { projectId, containerPath, hostPath });
|
||||||
|
export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) =>
|
||||||
|
invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir });
|
||||||
|
|
||||||
// Updates
|
// Updates
|
||||||
export const getAppVersion = () => invoke<string>("get_app_version");
|
export const getAppVersion = () => invoke<string>("get_app_version");
|
||||||
export const checkForUpdates = () =>
|
export const checkForUpdates = () =>
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export interface Project {
|
|||||||
auth_mode: AuthMode;
|
auth_mode: AuthMode;
|
||||||
bedrock_config: BedrockConfig | null;
|
bedrock_config: BedrockConfig | null;
|
||||||
allow_docker_access: boolean;
|
allow_docker_access: boolean;
|
||||||
|
mission_control_enabled: boolean;
|
||||||
ssh_key_path: string | null;
|
ssh_key_path: string | null;
|
||||||
git_token: string | null;
|
git_token: string | null;
|
||||||
git_user_name: string | null;
|
git_user_name: string | null;
|
||||||
@@ -77,6 +78,7 @@ export interface TerminalSession {
|
|||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
projectName: string;
|
projectName: string;
|
||||||
|
sessionType: "claude" | "bash";
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImageSource = "registry" | "local_build" | "custom";
|
export type ImageSource = "registry" | "local_build" | "custom";
|
||||||
@@ -134,3 +136,12 @@ export interface McpServer {
|
|||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileEntry {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
is_directory: boolean;
|
||||||
|
size: number;
|
||||||
|
modified: string;
|
||||||
|
permissions: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -116,6 +116,33 @@ if [ -n "$CLAUDE_INSTRUCTIONS" ]; then
|
|||||||
unset CLAUDE_INSTRUCTIONS
|
unset CLAUDE_INSTRUCTIONS
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# ── Mission Control setup ───────────────────────────────────────────────────
|
||||||
|
if [ "$MISSION_CONTROL_ENABLED" = "1" ]; then
|
||||||
|
MC_HOME="/home/claude/mission-control"
|
||||||
|
MC_LINK="/workspace/mission-control"
|
||||||
|
if [ ! -d "$MC_HOME/.git" ]; then
|
||||||
|
echo "entrypoint: cloning mission-control..."
|
||||||
|
su -s /bin/bash claude -c \
|
||||||
|
'git clone https://github.com/msieurthenardier/mission-control.git /home/claude/mission-control' \
|
||||||
|
|| echo "entrypoint: warning — failed to clone mission-control"
|
||||||
|
else
|
||||||
|
echo "entrypoint: mission-control already present, skipping clone"
|
||||||
|
fi
|
||||||
|
# Symlink into workspace so Claude sees it at /workspace/mission-control
|
||||||
|
ln -sfn "$MC_HOME" "$MC_LINK"
|
||||||
|
chown -h claude:claude "$MC_LINK"
|
||||||
|
|
||||||
|
# Install skills to ~/.claude/skills/ so Claude Code discovers them automatically
|
||||||
|
if [ -d "$MC_HOME/.claude/skills" ]; then
|
||||||
|
mkdir -p /home/claude/.claude/skills
|
||||||
|
cp -r "$MC_HOME/.claude/skills/"* /home/claude/.claude/skills/ 2>/dev/null
|
||||||
|
chown -R claude:claude /home/claude/.claude/skills
|
||||||
|
echo "entrypoint: mission-control skills installed to ~/.claude/skills/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
unset MISSION_CONTROL_ENABLED
|
||||||
|
fi
|
||||||
|
|
||||||
# ── MCP server configuration ────────────────────────────────────────────────
|
# ── MCP server configuration ────────────────────────────────────────────────
|
||||||
# Merge MCP server config into ~/.claude.json (preserves existing keys like
|
# Merge MCP server config into ~/.claude.json (preserves existing keys like
|
||||||
# OAuth tokens). Creates the file if it doesn't exist.
|
# OAuth tokens). Creates the file if it doesn't exist.
|
||||||
|
|||||||
Reference in New Issue
Block a user