Compare commits
20 Commits
v0.1.84-ma
...
v0.2.3-mac
| Author | SHA1 | Date | |
|---|---|---|---|
| cc163e6650 | |||
| 38082059a5 | |||
| beae0942a1 | |||
| 6b49981b3a | |||
| b46b392a9a | |||
| 4889dd974f | |||
| b6fd8a557e | |||
| 93deab68a7 | |||
| 2dce2993cc | |||
| e482452ffd | |||
| 8c710fc7bf | |||
| b7585420ef | |||
| bf8ef3dba1 | |||
| 418afe00ed | |||
| ab16ac11e7 | |||
| 429acd2fb5 | |||
| c853f2676d | |||
| 090aad6bc6 | |||
| c023d80c86 | |||
| 33f02e65c0 |
@@ -5,11 +5,14 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "VERSION"
|
||||
- ".gitea/workflows/build-app.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "app/**"
|
||||
- "VERSION"
|
||||
- ".gitea/workflows/build-app.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
@@ -17,10 +20,43 @@ env:
|
||||
REPO: ${{ gitea.repository }}
|
||||
|
||||
jobs:
|
||||
build-linux:
|
||||
compute-version:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.VERSION }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch all tags
|
||||
run: git fetch --tags
|
||||
|
||||
- name: Compute version from VERSION file and tags
|
||||
id: version
|
||||
run: |
|
||||
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
|
||||
echo "Major.Minor: ${MAJOR_MINOR}"
|
||||
|
||||
# Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes)
|
||||
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1)
|
||||
|
||||
if [ -n "$LATEST_TAG" ]; then
|
||||
echo "Latest matching tag: ${LATEST_TAG}"
|
||||
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
|
||||
else
|
||||
echo "No matching tag found for v${MAJOR_MINOR}.*, using total commit count"
|
||||
PATCH=$(git rev-list --count HEAD)
|
||||
fi
|
||||
|
||||
VERSION="${MAJOR_MINOR}.${PATCH}"
|
||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Computed version: ${VERSION}"
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compute-version]
|
||||
steps:
|
||||
- name: Install Node.js 22
|
||||
run: |
|
||||
@@ -53,17 +89,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute version
|
||||
id: version
|
||||
run: |
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
VERSION="0.1.${COMMIT_COUNT}"
|
||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Computed version: ${VERSION}"
|
||||
|
||||
- name: Set app version
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||
VERSION="${{ needs.compute-version.outputs.version }}"
|
||||
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
|
||||
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
|
||||
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
|
||||
@@ -132,7 +160,7 @@ jobs:
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
TAG="v${{ steps.version.outputs.VERSION }}"
|
||||
TAG="v${{ needs.compute-version.outputs.version }}"
|
||||
# Create release
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
@@ -155,6 +183,7 @@ jobs:
|
||||
|
||||
build-macos:
|
||||
runs-on: macos-latest
|
||||
needs: [compute-version]
|
||||
steps:
|
||||
- name: Install Node.js 22
|
||||
run: |
|
||||
@@ -182,17 +211,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute version
|
||||
id: version
|
||||
run: |
|
||||
COMMIT_COUNT=$(git rev-list --count HEAD)
|
||||
VERSION="0.1.${COMMIT_COUNT}"
|
||||
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "Computed version: ${VERSION}"
|
||||
|
||||
- name: Set app version
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.VERSION }}"
|
||||
VERSION="${{ needs.compute-version.outputs.version }}"
|
||||
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
|
||||
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
|
||||
sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
|
||||
@@ -242,12 +263,12 @@ jobs:
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
TAG="v${{ steps.version.outputs.VERSION }}-mac"
|
||||
TAG="v${{ needs.compute-version.outputs.version }}-mac"
|
||||
# Create release
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ steps.version.outputs.VERSION }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
|
||||
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
|
||||
echo "Release ID: ${RELEASE_ID}"
|
||||
@@ -265,6 +286,7 @@ jobs:
|
||||
|
||||
build-windows:
|
||||
runs-on: windows-latest
|
||||
needs: [compute-version]
|
||||
defaults:
|
||||
run:
|
||||
shell: cmd
|
||||
@@ -274,18 +296,10 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Compute version
|
||||
id: version
|
||||
run: |
|
||||
for /f %%i in ('git rev-list --count HEAD') do set "COMMIT_COUNT=%%i"
|
||||
set "VERSION=0.1.%COMMIT_COUNT%"
|
||||
echo VERSION=%VERSION%>> %GITHUB_OUTPUT%
|
||||
echo Computed version: %VERSION%
|
||||
|
||||
- name: Set app version
|
||||
shell: powershell
|
||||
run: |
|
||||
$version = "${{ steps.version.outputs.VERSION }}"
|
||||
$version = "${{ needs.compute-version.outputs.version }}"
|
||||
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
|
||||
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
|
||||
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
|
||||
@@ -366,9 +380,9 @@ jobs:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
COMMIT_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
set "TAG=v${{ steps.version.outputs.VERSION }}-win"
|
||||
set "TAG=v${{ needs.compute-version.outputs.version }}-win"
|
||||
echo Creating release %TAG%...
|
||||
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Triple-C v${{ steps.version.outputs.VERSION }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
|
||||
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
|
||||
for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found
|
||||
:found
|
||||
echo Release ID: %RELEASE_ID%
|
||||
@@ -377,9 +391,36 @@ jobs:
|
||||
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/octet-stream" --data-binary "@%%f" "%GITEA_URL%/api/v1/repos/%REPO%/releases/%RELEASE_ID%/assets?name=%%~nxf"
|
||||
)
|
||||
|
||||
create-tag:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compute-version, build-linux, build-macos, build-windows]
|
||||
if: gitea.event_name == 'push'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Create version tag
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
run: |
|
||||
VERSION="${{ needs.compute-version.outputs.version }}"
|
||||
TAG="v${VERSION}"
|
||||
echo "Creating tag ${TAG}..."
|
||||
|
||||
# Create annotated tag via Gitea API
|
||||
curl -s -X POST \
|
||||
-H "Authorization: token ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"tag_name\": \"${TAG}\", \"target\": \"${{ gitea.sha }}\", \"message\": \"Release ${TAG}\"}" \
|
||||
"${GITEA_URL}/api/v1/repos/${REPO}/tags" || echo "Tag may already exist (created by release)"
|
||||
|
||||
echo "Tag ${TAG} created successfully"
|
||||
|
||||
sync-to-github:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-linux, build-macos, build-windows]
|
||||
needs: [compute-version, build-linux, build-macos, build-windows]
|
||||
if: gitea.event_name == 'push'
|
||||
env:
|
||||
GH_PAT: ${{ secrets.GH_PAT }}
|
||||
@@ -388,7 +429,7 @@ jobs:
|
||||
- name: Download artifacts from Gitea releases
|
||||
env:
|
||||
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
VERSION: ${{ needs.build-linux.outputs.version }}
|
||||
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||
run: |
|
||||
set -e
|
||||
mkdir -p artifacts
|
||||
@@ -417,7 +458,7 @@ jobs:
|
||||
|
||||
- name: Create GitHub release and upload artifacts
|
||||
env:
|
||||
VERSION: ${{ needs.build-linux.outputs.version }}
|
||||
VERSION: ${{ needs.compute-version.outputs.version }}
|
||||
COMMIT_SHA: ${{ gitea.sha }}
|
||||
run: |
|
||||
set -e
|
||||
|
||||
@@ -5,10 +5,12 @@ on:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "container/**"
|
||||
- ".gitea/workflows/build.yml"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "container/**"
|
||||
- ".gitea/workflows/build.yml"
|
||||
|
||||
env:
|
||||
REGISTRY: repo.anhonesthost.net
|
||||
|
||||
@@ -72,7 +72,7 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
||||
- `exec.rs` — PTY exec sessions with bidirectional stdin/stdout streaming
|
||||
- `image.rs` — Image build/pull with progress streaming
|
||||
- **`models/`** — Serde structs (`Project`, `AuthMode`, `BedrockConfig`, `ContainerInfo`, `AppSettings`). These define the IPC contract with the frontend.
|
||||
- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `LiteLlmConfig`, `ContainerInfo`, `AppSettings`). These define the IPC contract with the frontend.
|
||||
- **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs`
|
||||
|
||||
### Container (`container/`)
|
||||
@@ -90,6 +90,8 @@ Containers use a **stop/start** model (not create/destroy). Installed packages p
|
||||
Per-project, independently configured:
|
||||
- **Anthropic (OAuth)** — `claude login` in terminal, token persists in config volume
|
||||
- **AWS Bedrock** — Static keys, profile, or bearer token injected as env vars
|
||||
- **Ollama** — Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`)
|
||||
- **LiteLLM** — Connect through a LiteLLM proxy gateway via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` to access 100+ model providers
|
||||
|
||||
## Styling
|
||||
|
||||
|
||||
257
HOW-TO-USE.md
257
HOW-TO-USE.md
@@ -33,6 +33,8 @@ You need access to Claude Code through one of:
|
||||
|
||||
- **Anthropic account** — Sign up at https://claude.ai and use `claude login` (OAuth) inside the terminal
|
||||
- **AWS Bedrock** — An AWS account with Bedrock access and Claude models enabled
|
||||
- **Ollama** — A local or remote Ollama server running an Anthropic-compatible model (best-effort support)
|
||||
- **LiteLLM** — A LiteLLM proxy gateway providing access to 100+ model providers (best-effort support)
|
||||
|
||||
---
|
||||
|
||||
@@ -65,11 +67,11 @@ Switch to the **Projects** tab in the sidebar and click the **+** button.
|
||||
|
||||
### 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -84,10 +86,24 @@ Claude Code launches automatically with `--dangerously-skip-permissions` inside
|
||||
**AWS Bedrock:**
|
||||
|
||||
1. Stop the container first (settings can only be changed while stopped).
|
||||
2. In the project card, switch the auth mode to **Bedrock**.
|
||||
2. In the project card, switch the backend to **Bedrock**.
|
||||
3. Expand the **Config** panel and fill in your AWS credentials (see [AWS Bedrock Configuration](#aws-bedrock-configuration) below).
|
||||
4. Start the container again.
|
||||
|
||||
**Ollama:**
|
||||
|
||||
1. Stop the container first (settings can only be changed while stopped).
|
||||
2. In the project card, switch the backend to **Ollama**.
|
||||
3. Expand the **Config** panel and set the base URL of your Ollama server (defaults to `http://host.docker.internal:11434` for a local instance). Optionally set a model ID.
|
||||
4. Start the container again.
|
||||
|
||||
**LiteLLM:**
|
||||
|
||||
1. Stop the container first (settings can only be changed while stopped).
|
||||
2. In the project card, switch the backend to **LiteLLM**.
|
||||
3. Expand the **Config** panel and set the base URL of your LiteLLM proxy (defaults to `http://host.docker.internal:4000`). Optionally set an API key and model ID.
|
||||
4. Start the container again.
|
||||
|
||||
---
|
||||
|
||||
## The Interface
|
||||
@@ -99,16 +115,16 @@ Claude Code launches automatically with `--dangerously-skip-permissions` inside
|
||||
│ Sidebar │ │
|
||||
│ │ Terminal View │
|
||||
│ Projects │ (xterm.js) │
|
||||
│ MCP │ │
|
||||
│ Settings │ │
|
||||
│ │ │
|
||||
├────────────┴────────────────────────────────────────┤
|
||||
│ 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).
|
||||
- **Sidebar** — Toggle between the **Projects** list and **Settings** panel.
|
||||
- **Terminal View** — Interactive terminal powered by xterm.js with WebGL rendering.
|
||||
- **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, **MCP** server configuration, and **Settings** panel.
|
||||
- **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.
|
||||
|
||||
---
|
||||
@@ -134,11 +150,17 @@ Select a project in the sidebar to see its action buttons:
|
||||
|--------|---------------|--------------|
|
||||
| **Start** | Stopped | Creates (if needed) and starts the container |
|
||||
| **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 |
|
||||
| **Config** | Always | Toggles the configuration panel |
|
||||
| **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
|
||||
|
||||
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 +169,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.
|
||||
|
||||
### 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
|
||||
@@ -177,6 +203,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.
|
||||
|
||||
### 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
|
||||
|
||||
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 +227,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.
|
||||
|
||||
Each mapping specifies:
|
||||
- **Host Port** — The port on your machine (1–65535)
|
||||
- **Container Port** — The port inside the container (1–65535)
|
||||
- **Host Port** — The port on your machine (1-65535)
|
||||
- **Container Port** — The port inside the container (1-65535)
|
||||
- **Protocol** — TCP (default) or UDP
|
||||
|
||||
### Claude Instructions
|
||||
@@ -198,9 +237,131 @@ 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
|
||||
|
||||
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 backend to **Bedrock** on the project card.
|
||||
|
||||
### Authentication Methods
|
||||
|
||||
@@ -227,6 +388,41 @@ Per-project settings always override these global defaults.
|
||||
|
||||
---
|
||||
|
||||
## Ollama Configuration
|
||||
|
||||
To use Claude Code with a local or remote Ollama server, switch the backend to **Ollama** on the project card.
|
||||
|
||||
### Settings
|
||||
|
||||
- **Base URL** — The URL of your Ollama server. Defaults to `http://host.docker.internal:11434`, which reaches a locally running Ollama instance from inside the container. For a remote server, use its IP or hostname (e.g., `http://192.168.1.100:11434`).
|
||||
- **Model ID** — Optional. Override the model to use (e.g., `qwen3.5:27b`).
|
||||
|
||||
### How It Works
|
||||
|
||||
Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your Ollama server instead of Anthropic's API. The `ANTHROPIC_AUTH_TOKEN` is set to `ollama` (required by Claude Code but not used for actual authentication).
|
||||
|
||||
> **Note:** Ollama support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models.
|
||||
|
||||
---
|
||||
|
||||
## LiteLLM Configuration
|
||||
|
||||
To use Claude Code through a [LiteLLM](https://docs.litellm.ai/) proxy gateway, switch the backend to **LiteLLM** on the project card. LiteLLM supports 100+ model providers (OpenAI, Gemini, Anthropic, and more) through a single proxy.
|
||||
|
||||
### Settings
|
||||
|
||||
- **Base URL** — The URL of your LiteLLM proxy. Defaults to `http://host.docker.internal:4000` for a locally running proxy.
|
||||
- **API Key** — Optional. The API key for your LiteLLM proxy, if authentication is required. Stored securely in your OS keychain.
|
||||
- **Model ID** — Optional. Override the model to use.
|
||||
|
||||
### How It Works
|
||||
|
||||
Triple-C sets `ANTHROPIC_BASE_URL` to point Claude Code at your LiteLLM proxy. If an API key is provided, it is set as `ANTHROPIC_AUTH_TOKEN`.
|
||||
|
||||
> **Note:** LiteLLM support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected when routing to non-Anthropic models through the proxy.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
Access global settings via the **Settings** tab in the sidebar.
|
||||
@@ -264,7 +460,11 @@ When an update is available, a pulsing **Update** button appears in the top bar.
|
||||
|
||||
### 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
|
||||
|
||||
@@ -272,9 +472,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.
|
||||
|
||||
### 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
|
||||
|
||||
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
|
||||
|
||||
@@ -356,6 +575,8 @@ The sandbox container (Ubuntu 24.04) comes pre-installed with:
|
||||
| build-essential | — | C/C++ compiler toolchain |
|
||||
| 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).
|
||||
|
||||
---
|
||||
@@ -378,7 +599,7 @@ You can install additional tools at runtime with `sudo apt install`, `pip instal
|
||||
|
||||
- Check that the Docker image is "Ready" in Settings.
|
||||
- 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
|
||||
|
||||
@@ -394,4 +615,10 @@ You can install additional tools at runtime with `sudo apt install`, `pip instal
|
||||
### 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.
|
||||
- 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.
|
||||
|
||||
80
README.md
80
README.md
@@ -27,10 +27,10 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
|
||||
### Container Lifecycle
|
||||
|
||||
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
|
||||
3. **Terminal**: `docker exec` launches Claude Code with a PTY
|
||||
4. **Stop**: Container halted (filesystem persists in named volume)
|
||||
5. **Restart**: Existing container restarted; recreated if settings changed (e.g., Docker access toggled)
|
||||
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 (or bash shell) with a PTY
|
||||
4. **Stop**: Container halted (filesystem persists in named volume); MCP containers stopped
|
||||
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)
|
||||
|
||||
### Mounts
|
||||
@@ -41,14 +41,18 @@ 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 |
|
||||
| `/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 |
|
||||
| `/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
|
||||
|
||||
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.
|
||||
- **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.
|
||||
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Optional model override.
|
||||
- **LiteLLM**: Connect through a LiteLLM proxy gateway via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` to access 100+ model providers. API key stored securely in OS keychain.
|
||||
|
||||
> **Note:** Ollama and LiteLLM support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
|
||||
|
||||
### Container Spawning (Sibling Containers)
|
||||
|
||||
@@ -56,6 +60,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.
|
||||
|
||||
### 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
|
||||
|
||||
The socket path is OS-aware:
|
||||
@@ -73,19 +102,34 @@ Users can override this in Settings via the global `docker_socket_path` option.
|
||||
| `app/src/components/layout/TopBar.tsx` | Terminal tabs + Docker/Image status indicators |
|
||||
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) |
|
||||
| `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, backend selector, action buttons |
|
||||
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
|
||||
| `app/src/components/settings/SettingsPanel.tsx` | API key, Docker, AWS settings |
|
||||
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection |
|
||||
| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions |
|
||||
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, inspection |
|
||||
| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions for terminal interaction |
|
||||
| `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) |
|
||||
| `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress |
|
||||
| `app/src/components/mcp/McpPanel.tsx` | MCP server library (global configuration) |
|
||||
| `app/src/components/mcp/McpServerCard.tsx` | Individual MCP server configuration card |
|
||||
| `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/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/models/project.rs` | Project struct (auth mode, Docker access, etc.) |
|
||||
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS) |
|
||||
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools |
|
||||
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config |
|
||||
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
|
||||
| `app/src-tauri/src/commands/mcp_commands.rs` | MCP server CRUD Tauri commands |
|
||||
| `app/src-tauri/src/models/project.rs` | Project struct (backend, Docker access, MCP servers, Mission Control) |
|
||||
| `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
|
||||
|
||||
@@ -100,4 +144,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
|
||||
|
||||
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
|
||||
**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)
|
||||
|
||||
130
TECHNICAL.md
130
TECHNICAL.md
@@ -154,13 +154,12 @@ The `.claude` configuration directory uses a **named Docker volume** (`triple-c-
|
||||
|
||||
### 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 |
|
||||
|------|-------------|-------------|
|
||||
| **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 |
|
||||
| **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, profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only. | Enterprise environments using Bedrock |
|
||||
| **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 |
|
||||
| **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 |
|
||||
|
||||
### UID/GID Remapping
|
||||
|
||||
@@ -213,66 +212,93 @@ The `TerminalView` component works around this with a **URL accumulator**:
|
||||
|
||||
```
|
||||
triple-c/
|
||||
├── LICENSE # MIT
|
||||
├── TECHNICAL.md # This document
|
||||
├── Triple-C.md # Project overview
|
||||
├── README.md # Architecture overview
|
||||
├── TECHNICAL.md # This document
|
||||
├── HOW-TO-USE.md # User guide
|
||||
├── BUILDING.md # Build instructions
|
||||
├── CLAUDE.md # Claude Code instructions
|
||||
│
|
||||
├── container/
|
||||
│ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code
|
||||
│ └── entrypoint.sh # UID/GID remap, SSH setup, git config
|
||||
│ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code
|
||||
│ ├── 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
|
||||
├── package.json # React, xterm.js, zustand, tailwindcss
|
||||
├── vite.config.ts # Vite bundler config
|
||||
├── index.html # HTML entry point
|
||||
├── .gitea/
|
||||
│ └── workflows/
|
||||
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
|
||||
│ ├── 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
|
||||
│ ├── main.tsx # React DOM root
|
||||
│ ├── App.tsx # Top-level layout
|
||||
│ ├── index.css # CSS variables, dark theme, scrollbars
|
||||
├── src/ # React frontend
|
||||
│ ├── main.tsx # React DOM root
|
||||
│ ├── App.tsx # Top-level layout
|
||||
│ ├── index.css # CSS variables, dark theme, scrollbars
|
||||
│ ├── store/
|
||||
│ │ └── appState.ts # Zustand store (projects, sessions, UI)
|
||||
│ │ └── appState.ts # Zustand store (projects, sessions, MCP, UI)
|
||||
│ ├── hooks/
|
||||
│ │ ├── useDocker.ts # Docker status, image build
|
||||
│ │ ├── useProjects.ts # Project CRUD operations
|
||||
│ │ ├── useSettings.ts # API key, app settings
|
||||
│ │ └── useTerminal.ts # Terminal I/O, resize, session events
|
||||
│ │ ├── useDocker.ts # Docker status, image build/pull
|
||||
│ │ ├── useFileManager.ts # File manager operations
|
||||
│ │ ├── useMcpServers.ts # MCP server CRUD
|
||||
│ │ ├── 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/
|
||||
│ │ ├── types.ts # TypeScript interfaces matching Rust models
|
||||
│ │ ├── tauri-commands.ts # Typed invoke() wrappers
|
||||
│ │ └── constants.ts # App-wide constants
|
||||
│ │ ├── types.ts # TypeScript interfaces matching Rust models
|
||||
│ │ ├── tauri-commands.ts # Typed invoke() wrappers
|
||||
│ │ └── constants.ts # App-wide constants
|
||||
│ └── components/
|
||||
│ ├── layout/ # Sidebar, TopBar, StatusBar
|
||||
│ ├── projects/ # ProjectList, ProjectCard, AddProjectDialog
|
||||
│ ├── terminal/ # TerminalView (xterm.js), TerminalTabs
|
||||
│ ├── settings/ # ApiKeyInput, DockerSettings, AwsSettings
|
||||
│ └── containers/ # SiblingContainers
|
||||
│ ├── layout/ # Sidebar, TopBar, StatusBar
|
||||
│ ├── mcp/ # McpPanel, McpServerCard
|
||||
│ ├── projects/ # ProjectCard, ProjectList, AddProjectDialog,
|
||||
│ │ # FileManagerModal, ContainerProgressModal, modals
|
||||
│ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings,
|
||||
│ │ # UpdateDialog
|
||||
│ └── terminal/ # TerminalView (xterm.js), TerminalTabs, UrlToast
|
||||
│
|
||||
└── src-tauri/ # Rust backend
|
||||
├── Cargo.toml # Rust dependencies
|
||||
├── tauri.conf.json # Tauri app configuration
|
||||
└── src-tauri/ # Rust backend
|
||||
├── Cargo.toml # Rust dependencies
|
||||
├── tauri.conf.json # Tauri app configuration
|
||||
├── capabilities/
|
||||
│ └── default.json # Tauri v2 permission grants
|
||||
│ └── default.json # Tauri v2 permission grants
|
||||
└── src/
|
||||
├── lib.rs # App builder, plugin + command registration
|
||||
├── main.rs # Entry point
|
||||
├── commands/ # Tauri command handlers
|
||||
│ ├── docker_commands.rs
|
||||
│ ├── project_commands.rs
|
||||
│ ├── settings_commands.rs
|
||||
│ └── terminal_commands.rs
|
||||
├── docker/ # Docker API layer
|
||||
│ ├── client.rs # bollard singleton connection
|
||||
│ ├── container.rs # Create, start, stop, remove, inspect
|
||||
│ ├── exec.rs # PTY exec sessions with bidirectional streaming
|
||||
│ ├── image.rs # Build from embedded Dockerfile, pull from registry
|
||||
│ └── sibling.rs # List non-Triple-C containers
|
||||
├── models/ # Data structures
|
||||
│ ├── project.rs # Project, AuthMode, BedrockConfig
|
||||
│ └── container_config.rs
|
||||
└── storage/ # Persistence
|
||||
├── lib.rs # App builder, plugin + command registration
|
||||
├── main.rs # Entry point
|
||||
├── logging.rs # Log configuration
|
||||
├── commands/ # Tauri command handlers
|
||||
│ ├── docker_commands.rs # Docker status, image ops
|
||||
│ ├── file_commands.rs # File manager (list/download/upload)
|
||||
│ ├── mcp_commands.rs # MCP server CRUD
|
||||
│ ├── project_commands.rs # Start/stop/rebuild containers
|
||||
│ ├── settings_commands.rs # Settings CRUD
|
||||
│ ├── terminal_commands.rs # Terminal I/O, resize
|
||||
│ └── update_commands.rs # App update checking
|
||||
├── docker/ # Docker API layer
|
||||
│ ├── client.rs # bollard singleton connection
|
||||
│ ├── container.rs # Create, start, stop, remove, fingerprinting
|
||||
│ ├── exec.rs # PTY exec sessions with bidirectional streaming
|
||||
│ ├── image.rs # Build from Dockerfile, pull from registry
|
||||
│ └── network.rs # Per-project bridge networks for MCP
|
||||
├── models/ # Data structures
|
||||
│ ├── project.rs # Project, Backend, 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
|
||||
├── 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
|
||||
```
|
||||
|
||||
|
||||
68
app/package-lock.json
generated
68
app/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "triple-c",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "triple-c",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2",
|
||||
@@ -1643,6 +1643,70 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
|
||||
"version": "1.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.1.0",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
|
||||
"version": "1.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.1.0",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1",
|
||||
"@tybys/wasm-util": "^0.10.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"dev": true,
|
||||
"inBundle": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "triple-c",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
2
app/src-tauri/Cargo.lock
generated
2
app/src-tauri/Cargo.lock
generated
@@ -4668,7 +4668,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "triple-c"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"bollard",
|
||||
"chrono",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "triple-c"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
30
app/src-tauri/src/commands/aws_commands.rs
Normal file
30
app/src-tauri/src/commands/aws_commands.rs
Normal file
@@ -0,0 +1,30 @@
|
||||
use tauri::State;
|
||||
use crate::AppState;
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn aws_sso_refresh(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let project = state.projects_store.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let profile = project.bedrock_config.as_ref()
|
||||
.and_then(|b| b.aws_profile.clone())
|
||||
.or_else(|| state.settings_store.get().global_aws.aws_profile.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
log::info!("Running host-side AWS SSO login for profile '{}'", profile);
|
||||
|
||||
let status = tokio::process::Command::new("aws")
|
||||
.args(["sso", "login", "--profile", &profile])
|
||||
.status()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to run aws sso login: {}", e))?;
|
||||
|
||||
if !status.success() {
|
||||
return Err("SSO login failed or was cancelled".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
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,6 @@
|
||||
pub mod aws_commands;
|
||||
pub mod docker_commands;
|
||||
pub mod file_commands;
|
||||
pub mod mcp_commands;
|
||||
pub mod project_commands;
|
||||
pub mod settings_commands;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
use crate::docker;
|
||||
use crate::models::{container_config, AuthMode, McpServer, Project, ProjectPath, ProjectStatus};
|
||||
use crate::models::{container_config, Backend, McpServer, Project, ProjectPath, ProjectStatus};
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -34,6 +34,11 @@ fn store_secrets_for_project(project: &Project) -> Result<(), String> {
|
||||
secure::store_project_secret(&project.id, "aws-bearer-token", v)?;
|
||||
}
|
||||
}
|
||||
if let Some(ref litellm) = project.litellm_config {
|
||||
if let Some(ref v) = litellm.api_key {
|
||||
secure::store_project_secret(&project.id, "litellm-api-key", v)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -51,6 +56,10 @@ fn load_secrets_for_project(project: &mut Project) {
|
||||
bedrock.aws_bearer_token = secure::get_project_secret(&project.id, "aws-bearer-token")
|
||||
.unwrap_or(None);
|
||||
}
|
||||
if let Some(ref mut litellm) = project.litellm_config {
|
||||
litellm.api_key = secure::get_project_secret(&project.id, "litellm-api-key")
|
||||
.unwrap_or(None);
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve enabled MCP servers and filter to Docker-only ones.
|
||||
@@ -170,13 +179,29 @@ pub async fn start_project_container(
|
||||
// Resolve enabled MCP servers for this project
|
||||
let (enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
|
||||
|
||||
// Validate auth mode requirements
|
||||
if project.auth_mode == AuthMode::Bedrock {
|
||||
// Validate backend requirements
|
||||
if project.backend == Backend::Bedrock {
|
||||
let bedrock = project.bedrock_config.as_ref()
|
||||
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
|
||||
.ok_or_else(|| "Bedrock backend selected but no Bedrock configuration found.".to_string())?;
|
||||
// Region can come from per-project or global
|
||||
if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() {
|
||||
return Err("AWS region is required for Bedrock auth mode. Set it per-project or in global AWS settings.".to_string());
|
||||
return Err("AWS region is required for Bedrock backend. Set it per-project or in global AWS settings.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if project.backend == Backend::Ollama {
|
||||
let ollama = project.ollama_config.as_ref()
|
||||
.ok_or_else(|| "Ollama backend selected but no Ollama configuration found.".to_string())?;
|
||||
if ollama.base_url.is_empty() {
|
||||
return Err("Ollama base URL is required.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if project.backend == Backend::LiteLlm {
|
||||
let litellm = project.litellm_config.as_ref()
|
||||
.ok_or_else(|| "LiteLLM backend selected but no LiteLLM configuration found.".to_string())?;
|
||||
if litellm.base_url.is_empty() {
|
||||
return Err("LiteLLM base URL is required.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,6 +227,28 @@ pub async fn start_project_container(
|
||||
|
||||
// Set up Docker network and MCP containers if needed
|
||||
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...");
|
||||
let net = docker::ensure_project_network(&project.id).await?;
|
||||
emit_progress(&app_handle, &project_id, "Starting MCP containers...");
|
||||
@@ -386,6 +433,46 @@ pub async fn rebuild_project_container(
|
||||
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 {
|
||||
if cfg!(target_os = "windows") {
|
||||
"//./pipe/docker_engine".to_string()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::models::{AuthMode, BedrockAuthMethod, Project};
|
||||
use crate::models::{Backend, BedrockAuthMethod, Project};
|
||||
use crate::AppState;
|
||||
|
||||
/// Build the command to run in the container terminal.
|
||||
@@ -9,7 +9,7 @@ use crate::AppState;
|
||||
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
|
||||
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
|
||||
fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
|
||||
let is_bedrock_profile = project.auth_mode == AuthMode::Bedrock
|
||||
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||
&& project
|
||||
.bedrock_config
|
||||
.as_ref()
|
||||
@@ -40,11 +40,12 @@ if aws sts get-caller-identity --profile '{profile}' >/dev/null 2>&1; then
|
||||
echo "AWS session valid."
|
||||
else
|
||||
echo "AWS session expired or invalid."
|
||||
# Check if this profile uses SSO (has sso_start_url configured)
|
||||
if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1; then
|
||||
echo "Starting SSO login — click the URL below to authenticate:"
|
||||
# Check if this profile uses SSO (has sso_start_url or sso_session configured)
|
||||
if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1 || \
|
||||
aws configure get sso_session --profile '{profile}' >/dev/null 2>&1; then
|
||||
echo "Starting SSO login..."
|
||||
echo ""
|
||||
aws sso login --profile '{profile}'
|
||||
triple-c-sso-refresh
|
||||
if [ $? -ne 0 ]; then
|
||||
echo ""
|
||||
echo "SSO login failed or was cancelled. Starting Claude anyway..."
|
||||
@@ -73,6 +74,7 @@ exec claude --dangerously-skip-permissions
|
||||
pub async fn open_terminal_session(
|
||||
project_id: String,
|
||||
session_id: String,
|
||||
session_type: Option<String>,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@@ -86,7 +88,10 @@ pub async fn open_terminal_session(
|
||||
.as_ref()
|
||||
.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 exit_event = format!("terminal-exit-{}", session_id);
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
use crate::models::{GiteaRelease, ReleaseAsset, UpdateInfo};
|
||||
use tauri::State;
|
||||
|
||||
use crate::docker;
|
||||
use crate::models::{container_config, GiteaRelease, ImageUpdateInfo, ReleaseAsset, UpdateInfo};
|
||||
use crate::AppState;
|
||||
|
||||
const RELEASES_URL: &str =
|
||||
"https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases";
|
||||
|
||||
/// Gitea container-registry tag object (v2 manifest).
|
||||
const REGISTRY_API_BASE: &str =
|
||||
"https://repo.anhonesthost.net/v2/cybercovellc/triple-c/triple-c-sandbox";
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_app_version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
@@ -115,3 +123,96 @@ fn extract_version_from_tag(tag: &str) -> Option<String> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a newer container image is available in the registry.
|
||||
///
|
||||
/// Compares the local image digest with the remote registry digest using the
|
||||
/// Docker Registry HTTP API v2. Only applies when the image source is
|
||||
/// "registry" (the default); for local builds or custom images we cannot
|
||||
/// meaningfully check for remote updates.
|
||||
#[tauri::command]
|
||||
pub async fn check_image_update(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Option<ImageUpdateInfo>, String> {
|
||||
let settings = state.settings_store.get();
|
||||
|
||||
// Only check for registry images
|
||||
if settings.image_source != crate::models::app_settings::ImageSource::Registry {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let image_name =
|
||||
container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||
|
||||
// 1. Get local image digest via Docker
|
||||
let local_digest = docker::get_local_image_digest(&image_name).await.ok().flatten();
|
||||
|
||||
// 2. Get remote digest from the Gitea container registry (OCI distribution spec)
|
||||
let remote_digest = fetch_remote_digest("latest").await?;
|
||||
|
||||
// No remote digest available — nothing to compare
|
||||
let remote_digest = match remote_digest {
|
||||
Some(d) => d,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
// If local digest matches remote, no update
|
||||
if let Some(ref local) = local_digest {
|
||||
if *local == remote_digest {
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
// There's a difference (or no local image at all)
|
||||
Ok(Some(ImageUpdateInfo {
|
||||
remote_digest,
|
||||
local_digest,
|
||||
remote_updated_at: None,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Fetch the digest of a tag from the Gitea container registry using the
|
||||
/// OCI / Docker Registry HTTP API v2.
|
||||
///
|
||||
/// We issue a HEAD request to /v2/<repo>/manifests/<tag> and read the
|
||||
/// `Docker-Content-Digest` header that the registry returns.
|
||||
async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> {
|
||||
let url = format!("{}/manifests/{}", REGISTRY_API_BASE, tag);
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
|
||||
|
||||
let response = client
|
||||
.head(&url)
|
||||
.header(
|
||||
"Accept",
|
||||
"application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json",
|
||||
)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(resp) => {
|
||||
if !resp.status().is_success() {
|
||||
log::warn!(
|
||||
"Registry returned status {} when checking image digest",
|
||||
resp.status()
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
// The digest is returned in the Docker-Content-Digest header
|
||||
if let Some(digest) = resp.headers().get("docker-content-digest") {
|
||||
if let Ok(val) = digest.to_str() {
|
||||
return Ok(Some(val.to_string()));
|
||||
}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to check registry for image update: {}", e);
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ use std::collections::HashMap;
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
use super::client::get_docker;
|
||||
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
|
||||
use crate::models::{Backend, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
|
||||
|
||||
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
|
||||
|
||||
@@ -40,6 +40,54 @@ After tasks run, check notifications with `triple-c-scheduler notifications` and
|
||||
### Timezone
|
||||
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
|
||||
/// instructions, appending port mapping docs, and appending scheduler docs.
|
||||
/// Used by both create_container() and container_needs_recreation() to ensure
|
||||
@@ -48,8 +96,13 @@ fn build_claude_instructions(
|
||||
global_instructions: Option<&str>,
|
||||
project_instructions: Option<&str>,
|
||||
port_mappings: &[PortMapping],
|
||||
mission_control_enabled: bool,
|
||||
) -> 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() {
|
||||
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.
|
||||
/// When mission_control_enabled is true, appends Mission Control global
|
||||
/// instructions after global and project instructions after project.
|
||||
fn merge_claude_instructions(
|
||||
global_instructions: Option<&str>,
|
||||
project_instructions: Option<&str>,
|
||||
mission_control_enabled: bool,
|
||||
) -> 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), None) => Some(g.to_string()),
|
||||
(None, Some(p)) => Some(p.to_string()),
|
||||
(Some(g), None) => Some(g),
|
||||
(None, Some(p)) => Some(p),
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
@@ -155,6 +231,33 @@ fn compute_bedrock_fingerprint(project: &Project) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a fingerprint for the Ollama configuration so we can detect changes.
|
||||
fn compute_ollama_fingerprint(project: &Project) -> String {
|
||||
if let Some(ref ollama) = project.ollama_config {
|
||||
let parts = vec![
|
||||
ollama.base_url.clone(),
|
||||
ollama.model_id.as_deref().unwrap_or("").to_string(),
|
||||
];
|
||||
sha256_hex(&parts.join("|"))
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a fingerprint for the LiteLLM configuration so we can detect changes.
|
||||
fn compute_litellm_fingerprint(project: &Project) -> String {
|
||||
if let Some(ref litellm) = project.litellm_config {
|
||||
let parts = vec![
|
||||
litellm.base_url.clone(),
|
||||
litellm.api_key.as_deref().unwrap_or("").to_string(),
|
||||
litellm.model_id.as_deref().unwrap_or("").to_string(),
|
||||
];
|
||||
sha256_hex(&parts.join("|"))
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute a fingerprint for the project paths so we can detect changes.
|
||||
/// Sorted by mount_name so order changes don't cause spurious recreation.
|
||||
fn compute_paths_fingerprint(paths: &[ProjectPath]) -> String {
|
||||
@@ -350,7 +453,7 @@ pub async fn create_container(
|
||||
}
|
||||
|
||||
// Bedrock configuration
|
||||
if project.auth_mode == AuthMode::Bedrock {
|
||||
if project.backend == Backend::Bedrock {
|
||||
if let Some(ref bedrock) = project.bedrock_config {
|
||||
env_vars.push("CLAUDE_CODE_USE_BEDROCK=1".to_string());
|
||||
|
||||
@@ -383,6 +486,7 @@ pub async fn create_container(
|
||||
if let Some(p) = profile {
|
||||
env_vars.push(format!("AWS_PROFILE={}", p));
|
||||
}
|
||||
env_vars.push("AWS_SSO_AUTH_REFRESH_CMD=triple-c-sso-refresh".to_string());
|
||||
}
|
||||
BedrockAuthMethod::BearerToken => {
|
||||
if let Some(ref token) = bedrock.aws_bearer_token {
|
||||
@@ -401,6 +505,30 @@ pub async fn create_container(
|
||||
}
|
||||
}
|
||||
|
||||
// Ollama configuration
|
||||
if project.backend == Backend::Ollama {
|
||||
if let Some(ref ollama) = project.ollama_config {
|
||||
env_vars.push(format!("ANTHROPIC_BASE_URL={}", ollama.base_url));
|
||||
env_vars.push("ANTHROPIC_AUTH_TOKEN=ollama".to_string());
|
||||
if let Some(ref model) = ollama.model_id {
|
||||
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LiteLLM configuration
|
||||
if project.backend == Backend::LiteLlm {
|
||||
if let Some(ref litellm) = project.litellm_config {
|
||||
env_vars.push(format!("ANTHROPIC_BASE_URL={}", litellm.base_url));
|
||||
if let Some(ref key) = litellm.api_key {
|
||||
env_vars.push(format!("ANTHROPIC_AUTH_TOKEN={}", key));
|
||||
}
|
||||
if let Some(ref model) = litellm.model_id {
|
||||
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom environment variables (global + per-project, project overrides global for same key)
|
||||
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "CLAUDE_", "TRIPLE_C_"];
|
||||
@@ -426,11 +554,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)
|
||||
let combined_instructions = build_claude_instructions(
|
||||
global_claude_instructions,
|
||||
project.claude_instructions.as_deref(),
|
||||
&project.port_mappings,
|
||||
project.mission_control_enabled,
|
||||
);
|
||||
|
||||
if let Some(ref instructions) = combined_instructions {
|
||||
@@ -490,7 +624,7 @@ pub async fn create_container(
|
||||
|
||||
// AWS config mount (read-only)
|
||||
// Mount if: Bedrock profile auth needs it, OR a global aws_config_path is set
|
||||
let should_mount_aws = if project.auth_mode == AuthMode::Bedrock {
|
||||
let should_mount_aws = if project.backend == Backend::Bedrock {
|
||||
if let Some(ref bedrock) = project.bedrock_config {
|
||||
bedrock.auth_method == BedrockAuthMethod::Profile
|
||||
} else {
|
||||
@@ -560,13 +694,16 @@ pub async fn create_container(
|
||||
labels.insert("triple-c.managed".to_string(), "true".to_string());
|
||||
labels.insert("triple-c.project-id".to_string(), project.id.clone());
|
||||
labels.insert("triple-c.project-name".to_string(), project.name.clone());
|
||||
labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode));
|
||||
labels.insert("triple-c.backend".to_string(), format!("{:?}", project.backend));
|
||||
labels.insert("triple-c.paths-fingerprint".to_string(), compute_paths_fingerprint(&project.paths));
|
||||
labels.insert("triple-c.bedrock-fingerprint".to_string(), compute_bedrock_fingerprint(project));
|
||||
labels.insert("triple-c.ollama-fingerprint".to_string(), compute_ollama_fingerprint(project));
|
||||
labels.insert("triple-c.litellm-fingerprint".to_string(), compute_litellm_fingerprint(project));
|
||||
labels.insert("triple-c.ports-fingerprint".to_string(), compute_ports_fingerprint(&project.port_mappings));
|
||||
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.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 {
|
||||
mounts: Some(mounts),
|
||||
@@ -760,11 +897,13 @@ pub async fn container_needs_recreation(
|
||||
// Code settings stored in the named volume). The change takes effect
|
||||
// on the next explicit rebuild instead.
|
||||
|
||||
// ── Auth mode ────────────────────────────────────────────────────────
|
||||
let current_auth_mode = format!("{:?}", project.auth_mode);
|
||||
if let Some(container_auth_mode) = get_label("triple-c.auth-mode") {
|
||||
if container_auth_mode != current_auth_mode {
|
||||
log::info!("Auth mode mismatch (container={:?}, project={:?})", container_auth_mode, current_auth_mode);
|
||||
// ── Backend ──────────────────────────────────────────────────────────
|
||||
let current_backend = format!("{:?}", project.backend);
|
||||
// Check new label name, falling back to old "triple-c.auth-mode" for pre-rename containers
|
||||
let container_backend = get_label("triple-c.backend").or_else(|| get_label("triple-c.auth-mode"));
|
||||
if let Some(container_backend) = container_backend {
|
||||
if container_backend != current_backend {
|
||||
log::info!("Backend mismatch (container={:?}, project={:?})", container_backend, current_backend);
|
||||
return Ok(true);
|
||||
}
|
||||
}
|
||||
@@ -801,6 +940,22 @@ pub async fn container_needs_recreation(
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── Ollama config fingerprint ────────────────────────────────────────
|
||||
let expected_ollama_fp = compute_ollama_fingerprint(project);
|
||||
let container_ollama_fp = get_label("triple-c.ollama-fingerprint").unwrap_or_default();
|
||||
if container_ollama_fp != expected_ollama_fp {
|
||||
log::info!("Ollama config mismatch");
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── LiteLLM config fingerprint ───────────────────────────────────────
|
||||
let expected_litellm_fp = compute_litellm_fingerprint(project);
|
||||
let container_litellm_fp = get_label("triple-c.litellm-fingerprint").unwrap_or_default();
|
||||
if container_litellm_fp != expected_litellm_fp {
|
||||
log::info!("LiteLLM config mismatch");
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// ── Image ────────────────────────────────────────────────────────────
|
||||
// The image label is set at creation time; if the user changed the
|
||||
// configured image we need to recreate. We only compare when the
|
||||
@@ -885,11 +1040,20 @@ pub async fn container_needs_recreation(
|
||||
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 ───────────────────────────────────────────────
|
||||
let expected_instructions = build_claude_instructions(
|
||||
global_claude_instructions,
|
||||
project.claude_instructions.as_deref(),
|
||||
&project.port_mappings,
|
||||
project.mission_control_enabled,
|
||||
);
|
||||
let container_instructions = get_env("CLAUDE_INSTRUCTIONS");
|
||||
if container_instructions.as_deref() != expected_instructions.as_deref() {
|
||||
@@ -939,6 +1103,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> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -971,11 +1145,6 @@ pub fn any_stdio_docker_mcp(servers: &[McpServer]) -> bool {
|
||||
servers.iter().any(|s| s.is_docker() && s.transport_type == McpTransportType::Stdio)
|
||||
}
|
||||
|
||||
/// Returns true if any MCP server uses Docker.
|
||||
pub fn any_docker_mcp(servers: &[McpServer]) -> bool {
|
||||
servers.iter().any(|s| s.is_docker())
|
||||
}
|
||||
|
||||
/// Find an existing MCP container by its expected name.
|
||||
pub async fn find_mcp_container(server: &McpServer) -> Result<Option<String>, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -22,6 +22,7 @@ impl ExecSession {
|
||||
.map_err(|e| format!("Failed to send input: {}", e))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
@@ -277,3 +278,41 @@ impl ExecSessionManager {
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,38 @@ pub async fn image_exists(image_name: &str) -> Result<bool, String> {
|
||||
Ok(!images.is_empty())
|
||||
}
|
||||
|
||||
/// Returns the first repo digest (e.g. "sha256:abc...") for the given image,
|
||||
/// or None if the image doesn't exist locally or has no repo digests.
|
||||
pub async fn get_local_image_digest(image_name: &str) -> Result<Option<String>, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||
"reference".to_string(),
|
||||
vec![image_name.to_string()],
|
||||
)]);
|
||||
|
||||
let images: Vec<ImageSummary> = docker
|
||||
.list_images(Some(ListImagesOptions {
|
||||
filters,
|
||||
..Default::default()
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| format!("Failed to list images: {}", e))?;
|
||||
|
||||
if let Some(img) = images.first() {
|
||||
// RepoDigests contains entries like "registry/repo@sha256:abc..."
|
||||
if let Some(digest_str) = img.repo_digests.first() {
|
||||
// Extract the sha256:... part after '@'
|
||||
if let Some(pos) = digest_str.find('@') {
|
||||
return Ok(Some(digest_str[pos + 1..].to_string()));
|
||||
}
|
||||
return Ok(Some(digest_str.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub async fn pull_image<F>(image_name: &str, on_progress: F) -> Result<(), String>
|
||||
where
|
||||
F: Fn(String) + Send + 'static,
|
||||
|
||||
@@ -4,8 +4,13 @@ pub mod image;
|
||||
pub mod exec;
|
||||
pub mod network;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use client::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use container::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use image::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use exec::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use network::*;
|
||||
|
||||
@@ -48,6 +48,7 @@ pub async fn ensure_project_network(project_id: &str) -> Result<String, String>
|
||||
}
|
||||
|
||||
/// Connect a container to the project network.
|
||||
#[allow(dead_code)]
|
||||
pub async fn connect_container_to_network(
|
||||
container_id: &str,
|
||||
network_name: &str,
|
||||
|
||||
@@ -88,6 +88,7 @@ pub fn run() {
|
||||
commands::project_commands::start_project_container,
|
||||
commands::project_commands::stop_project_container,
|
||||
commands::project_commands::rebuild_project_container,
|
||||
commands::project_commands::reconcile_project_statuses,
|
||||
// Settings
|
||||
commands::settings_commands::get_settings,
|
||||
commands::settings_commands::update_settings,
|
||||
@@ -104,14 +105,21 @@ pub fn run() {
|
||||
commands::terminal_commands::start_audio_bridge,
|
||||
commands::terminal_commands::send_audio_data,
|
||||
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
|
||||
commands::mcp_commands::list_mcp_servers,
|
||||
commands::mcp_commands::add_mcp_server,
|
||||
commands::mcp_commands::update_mcp_server,
|
||||
commands::mcp_commands::remove_mcp_server,
|
||||
// AWS
|
||||
commands::aws_commands::aws_sso_refresh,
|
||||
// Updates
|
||||
commands::update_commands::get_app_version,
|
||||
commands::update_commands::check_for_updates,
|
||||
commands::update_commands::check_image_update,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -72,6 +72,8 @@ pub struct AppSettings {
|
||||
pub timezone: Option<String>,
|
||||
#[serde(default)]
|
||||
pub default_microphone: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dismissed_image_digest: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
@@ -90,6 +92,7 @@ impl Default for AppSettings {
|
||||
dismissed_update_version: None,
|
||||
timezone: None,
|
||||
default_microphone: None,
|
||||
dismissed_image_digest: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,11 +31,16 @@ pub struct Project {
|
||||
pub paths: Vec<ProjectPath>,
|
||||
pub container_id: Option<String>,
|
||||
pub status: ProjectStatus,
|
||||
pub auth_mode: AuthMode,
|
||||
#[serde(alias = "auth_mode")]
|
||||
pub backend: Backend,
|
||||
pub bedrock_config: Option<BedrockConfig>,
|
||||
pub ollama_config: Option<OllamaConfig>,
|
||||
pub litellm_config: Option<LiteLlmConfig>,
|
||||
pub allow_docker_access: bool,
|
||||
#[serde(default)]
|
||||
pub mission_control_enabled: bool,
|
||||
pub ssh_key_path: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub git_token: Option<String>,
|
||||
pub git_user_name: Option<String>,
|
||||
pub git_user_email: Option<String>,
|
||||
@@ -61,20 +66,24 @@ pub enum ProjectStatus {
|
||||
Error,
|
||||
}
|
||||
|
||||
/// How the project authenticates with Claude.
|
||||
/// - `Anthropic`: User runs `claude login` inside the container (OAuth via Anthropic Console,
|
||||
/// persisted in the config volume)
|
||||
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
|
||||
/// Which AI model backend/provider the project uses.
|
||||
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
|
||||
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
|
||||
/// - `Ollama`: Local or remote Ollama server
|
||||
/// - `LiteLlm`: LiteLLM proxy gateway for 100+ model providers
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AuthMode {
|
||||
pub enum Backend {
|
||||
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
|
||||
#[serde(alias = "login", alias = "api_key")]
|
||||
Anthropic,
|
||||
Bedrock,
|
||||
Ollama,
|
||||
#[serde(alias = "litellm")]
|
||||
LiteLlm,
|
||||
}
|
||||
|
||||
impl Default for AuthMode {
|
||||
impl Default for Backend {
|
||||
fn default() -> Self {
|
||||
Self::Anthropic
|
||||
}
|
||||
@@ -100,19 +109,42 @@ impl Default for BedrockAuthMethod {
|
||||
pub struct BedrockConfig {
|
||||
pub auth_method: BedrockAuthMethod,
|
||||
pub aws_region: String,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub aws_access_key_id: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub aws_secret_access_key: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub aws_session_token: Option<String>,
|
||||
pub aws_profile: Option<String>,
|
||||
#[serde(skip_serializing)]
|
||||
#[serde(skip_serializing, default)]
|
||||
pub aws_bearer_token: Option<String>,
|
||||
pub model_id: Option<String>,
|
||||
pub disable_prompt_caching: bool,
|
||||
}
|
||||
|
||||
/// Ollama configuration for a project.
|
||||
/// Ollama exposes an Anthropic-compatible API endpoint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OllamaConfig {
|
||||
/// The base URL of the Ollama server (e.g., "http://host.docker.internal:11434" or "http://192.168.1.100:11434")
|
||||
pub base_url: String,
|
||||
/// Optional model override (e.g., "qwen3.5:27b")
|
||||
pub model_id: Option<String>,
|
||||
}
|
||||
|
||||
/// LiteLLM gateway configuration for a project.
|
||||
/// LiteLLM translates Anthropic API calls to 100+ model providers.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LiteLlmConfig {
|
||||
/// The base URL of the LiteLLM proxy (e.g., "http://host.docker.internal:4000" or "https://litellm.example.com")
|
||||
pub base_url: String,
|
||||
/// API key for the LiteLLM proxy
|
||||
#[serde(skip_serializing, default)]
|
||||
pub api_key: Option<String>,
|
||||
/// Optional model override
|
||||
pub model_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Project {
|
||||
pub fn new(name: String, paths: Vec<ProjectPath>) -> Self {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
@@ -122,9 +154,12 @@ impl Project {
|
||||
paths,
|
||||
container_id: None,
|
||||
status: ProjectStatus::Stopped,
|
||||
auth_mode: AuthMode::default(),
|
||||
backend: Backend::default(),
|
||||
bedrock_config: None,
|
||||
ollama_config: None,
|
||||
litellm_config: None,
|
||||
allow_docker_access: false,
|
||||
mission_control_enabled: false,
|
||||
ssh_key_path: None,
|
||||
git_token: None,
|
||||
git_user_name: None,
|
||||
|
||||
@@ -35,3 +35,14 @@ pub struct GiteaAsset {
|
||||
pub browser_download_url: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
/// Info returned to the frontend about an available container image update.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ImageUpdateInfo {
|
||||
/// The remote digest (e.g. sha256:abc...)
|
||||
pub remote_digest: String,
|
||||
/// The local digest, if available
|
||||
pub local_digest: Option<String>,
|
||||
/// When the remote image was last updated (if known)
|
||||
pub remote_updated_at: Option<String>,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,11 @@ pub mod secure;
|
||||
pub mod settings_store;
|
||||
pub mod mcp_store;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use projects_store::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use secure::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use settings_store::*;
|
||||
#[allow(unused_imports)]
|
||||
pub use mcp_store::*;
|
||||
|
||||
@@ -72,6 +72,8 @@ impl ProjectsStore {
|
||||
|
||||
// Reconcile stale transient statuses: on a cold app start no Docker
|
||||
// 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 needs_save = needs_save;
|
||||
for p in projects.iter_mut() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
||||
"productName": "Triple-C",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"identifier": "com.triple-c.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
@@ -10,15 +10,16 @@ import { useProjects } from "./hooks/useProjects";
|
||||
import { useMcpServers } from "./hooks/useMcpServers";
|
||||
import { useUpdates } from "./hooks/useUpdates";
|
||||
import { useAppState } from "./store/appState";
|
||||
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
||||
|
||||
export default function App() {
|
||||
const { checkDocker, checkImage, startDockerPolling } = useDocker();
|
||||
const { loadSettings } = useSettings();
|
||||
const { refresh } = useProjects();
|
||||
const { refresh: refreshMcp } = useMcpServers();
|
||||
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
|
||||
const { sessions, activeSessionId } = useAppState(
|
||||
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId }))
|
||||
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
|
||||
const { sessions, activeSessionId, setProjects } = useAppState(
|
||||
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects }))
|
||||
);
|
||||
|
||||
// Initialize on mount
|
||||
@@ -28,6 +29,14 @@ export default function App() {
|
||||
checkDocker().then((available) => {
|
||||
if (available) {
|
||||
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 {
|
||||
stopPolling = startDockerPolling();
|
||||
}
|
||||
@@ -37,7 +46,10 @@ export default function App() {
|
||||
|
||||
// Update detection
|
||||
loadVersion();
|
||||
const updateTimer = setTimeout(() => checkForUpdates(), 3000);
|
||||
const updateTimer = setTimeout(() => {
|
||||
checkForUpdates();
|
||||
checkImageUpdate();
|
||||
}, 3000);
|
||||
const cleanup = startPeriodicCheck();
|
||||
return () => {
|
||||
clearTimeout(updateTimer);
|
||||
|
||||
775
app/src/components/layout/HelpDialog.tsx
Normal file
775
app/src/components/layout/HelpDialog.tsx
Normal file
@@ -0,0 +1,775 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const HELP_MARKDOWN = `# How to Use Triple-C
|
||||
|
||||
Triple-C (Claude-Code-Container) is a desktop application that runs Claude Code inside isolated Docker containers. Each project gets its own sandboxed environment with bind-mounted directories, so Claude only has access to the files you explicitly provide.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
### Docker
|
||||
|
||||
Triple-C requires a running Docker daemon. Install one of the following:
|
||||
|
||||
| Platform | Option | Link |
|
||||
|----------|--------|------|
|
||||
| **Windows** | Docker Desktop | https://docs.docker.com/desktop/install/windows-install/ |
|
||||
| **macOS** | Docker Desktop | https://docs.docker.com/desktop/install/mac-install/ |
|
||||
| **Linux** | Docker Engine | https://docs.docker.com/engine/install/ |
|
||||
| **Linux** | Docker Desktop (alternative) | https://docs.docker.com/desktop/install/linux/ |
|
||||
|
||||
After installation, verify Docker is running:
|
||||
|
||||
\`\`\`bash
|
||||
docker info
|
||||
\`\`\`
|
||||
|
||||
> **Windows note:** Docker Desktop must be running before launching Triple-C. The app communicates with Docker through the named pipe at \`//./pipe/docker_engine\`.
|
||||
|
||||
> **Linux note:** Your user must have permission to access the Docker socket (\`/var/run/docker.sock\`). Either add your user to the \`docker\` group (\`sudo usermod -aG docker $USER\`, then log out and back in) or run Docker in rootless mode.
|
||||
|
||||
### Claude Code Account
|
||||
|
||||
You need access to Claude Code through one of:
|
||||
|
||||
- **Anthropic account** — Sign up at https://claude.ai and use \`claude login\` (OAuth) inside the terminal
|
||||
- **AWS Bedrock** — An AWS account with Bedrock access and Claude models enabled
|
||||
- **Ollama** — A local or remote Ollama server running an Anthropic-compatible model (best-effort support)
|
||||
- **LiteLLM** — A LiteLLM proxy gateway providing access to 100+ model providers (best-effort support)
|
||||
|
||||
---
|
||||
|
||||
## First Launch
|
||||
|
||||
### 1. Get the Container Image
|
||||
|
||||
When you first open Triple-C, go to the **Settings** tab in the sidebar. Under **Docker**, you'll see:
|
||||
|
||||
- **Docker Status** — Should show "Connected" (green). If it shows "Not Available", make sure Docker is running.
|
||||
- **Image Status** — Will show "Not Found" on first launch.
|
||||
|
||||
Choose an **Image Source**:
|
||||
|
||||
| Source | Description | When to Use |
|
||||
|--------|-------------|-------------|
|
||||
| **Registry** | Pulls the pre-built image from \`repo.anhonesthost.net\` | Fastest setup — recommended for most users |
|
||||
| **Local Build** | Builds the image locally from the embedded Dockerfile | If you can't reach the registry, or want a custom build |
|
||||
| **Custom** | Use any Docker image you specify | Advanced — bring your own sandbox image |
|
||||
|
||||
Click **Pull Image** (for Registry/Custom) or **Build Image** (for Local Build). A progress log will stream below the button. When complete, the status changes to "Ready" (green).
|
||||
|
||||
### 2. Create Your First Project
|
||||
|
||||
Switch to the **Projects** tab in the sidebar and click the **+** button.
|
||||
|
||||
1. **Project Name** — Give it a meaningful name (e.g., "my-web-app").
|
||||
2. **Folders** — Click **Browse** to select a directory on your host machine. This directory will be mounted into the container at \`/workspace/<folder-name>\`. You can add multiple folders with the **+** button at the bottom of the folder list.
|
||||
3. Click **Add Project**.
|
||||
|
||||
### 3. Start the Container
|
||||
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
### 5. Authenticate
|
||||
|
||||
**Anthropic (OAuth) — default:**
|
||||
|
||||
1. Type \`claude login\` or \`/login\` in the terminal.
|
||||
2. Claude prints an OAuth URL. Triple-C detects long URLs and shows a clickable toast at the top of the terminal — click **Open** to open it in your browser.
|
||||
3. Complete the login in your browser. The token is saved and persists across container stops and resets.
|
||||
|
||||
**AWS Bedrock:**
|
||||
|
||||
1. Stop the container first (settings can only be changed while stopped).
|
||||
2. In the project card, switch the backend to **Bedrock**.
|
||||
3. Expand the **Config** panel and fill in your AWS credentials (see AWS Bedrock Configuration below).
|
||||
4. Start the container again.
|
||||
|
||||
**Ollama:**
|
||||
|
||||
1. Stop the container first (settings can only be changed while stopped).
|
||||
2. In the project card, switch the backend to **Ollama**.
|
||||
3. Expand the **Config** panel and set the base URL of your Ollama server (defaults to \`http://host.docker.internal:11434\` for a local instance). Optionally set a model ID.
|
||||
4. Start the container again.
|
||||
|
||||
**LiteLLM:**
|
||||
|
||||
1. Stop the container first (settings can only be changed while stopped).
|
||||
2. In the project card, switch the backend to **LiteLLM**.
|
||||
3. Expand the **Config** panel and set the base URL of your LiteLLM proxy (defaults to \`http://host.docker.internal:4000\`). Optionally set an API key and model ID.
|
||||
4. Start the container again.
|
||||
|
||||
---
|
||||
|
||||
## The Interface
|
||||
|
||||
\`\`\`
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ TopBar [ Terminal Tabs ] Docker ● Image ●│
|
||||
├────────────┬────────────────────────────────────────┤
|
||||
│ Sidebar │ │
|
||||
│ │ Terminal View │
|
||||
│ Projects │ (xterm.js) │
|
||||
│ MCP │ │
|
||||
│ Settings │ │
|
||||
├────────────┴────────────────────────────────────────┤
|
||||
│ StatusBar X projects · X running · X terminals │
|
||||
└─────────────────────────────────────────────────────┘
|
||||
\`\`\`
|
||||
|
||||
- **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, **MCP** server configuration, and **Settings** panel.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
## Project Management
|
||||
|
||||
### Project Status
|
||||
|
||||
Each project shows a colored status dot:
|
||||
|
||||
| Color | Status | Meaning |
|
||||
|-------|--------|---------|
|
||||
| Gray | Stopped | Container is not running |
|
||||
| Orange | Starting / Stopping | Container is transitioning |
|
||||
| Green | Running | Container is active, ready for terminals |
|
||||
| Red | Error | Something went wrong (check error message) |
|
||||
|
||||
### Project Actions
|
||||
|
||||
Select a project in the sidebar to see its action buttons:
|
||||
|
||||
| Button | When Available | What It Does |
|
||||
|--------|---------------|--------------|
|
||||
| **Start** | Stopped | Creates (if needed) and starts the container |
|
||||
| **Stop** | Running | Stops the container but preserves its state |
|
||||
| **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 |
|
||||
| **Config** | Always | Toggles the configuration panel |
|
||||
| **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
|
||||
|
||||
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.
|
||||
|
||||
**Reset** removes the container and creates a fresh one. However, your Claude Code configuration (including OAuth tokens from \`claude login\`) is stored in a separate Docker volume and survives resets.
|
||||
|
||||
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
|
||||
|
||||
Click **Config** on a selected project to expand the configuration panel. Settings can only be changed when the container is **stopped** (an orange warning box appears if the container is running).
|
||||
|
||||
### Mounted Folders
|
||||
|
||||
Each project mounts one or more host directories into the container. The mount appears at \`/workspace/<mount-name>\` inside the container.
|
||||
|
||||
- Click **Browse** ("...") to change the host path
|
||||
- Edit the mount name to control where it appears inside \`/workspace/\`
|
||||
- Click **+** to add more folders, or **x** to remove one
|
||||
- Mount names must be unique and use only letters, numbers, dashes, underscores, and dots
|
||||
|
||||
### SSH Keys
|
||||
|
||||
Specify the path to your SSH key directory (typically \`~/.ssh\`). Keys are mounted read-only and copied into the container with correct permissions. This enables \`git clone\` via SSH inside the container.
|
||||
|
||||
### Git Configuration
|
||||
|
||||
- **Git Name / Email** — Sets \`git config user.name\` and \`user.email\` inside the container.
|
||||
- **Git HTTPS Token** — A personal access token (e.g., from GitHub) for HTTPS git operations. Stored securely in your OS keychain — never written to disk in plaintext.
|
||||
|
||||
### Allow Container Spawning
|
||||
|
||||
When enabled, the host Docker socket is mounted into the container so Claude Code can create sibling containers (e.g., for running databases, test environments). This is **off by default** for security.
|
||||
|
||||
> Toggling this requires stopping and restarting the container to take effect.
|
||||
|
||||
### Mission Control
|
||||
|
||||
Toggle **Mission Control** to integrate Flight 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
|
||||
|
||||
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.
|
||||
|
||||
> Reserved prefixes (\`ANTHROPIC_\`, \`AWS_\`, \`GIT_\`, \`HOST_\`, \`CLAUDE_\`, \`TRIPLE_C_\`) are filtered out to prevent conflicts with internal variables.
|
||||
|
||||
### Port Mappings
|
||||
|
||||
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:
|
||||
- **Host Port** — The port on your machine (1-65535)
|
||||
- **Container Port** — The port inside the container (1-65535)
|
||||
- **Protocol** — TCP (default) or UDP
|
||||
|
||||
### Claude Instructions
|
||||
|
||||
Click **Edit** to write per-project instructions for Claude Code. These are written to \`~/.claude/CLAUDE.md\` inside the container and provide project-specific context. If you also have global instructions (in Settings), the global instructions come first, followed by the per-project instructions.
|
||||
|
||||
---
|
||||
|
||||
## MCP Servers (Beta)
|
||||
|
||||
Triple-C supports Model Context Protocol (MCP) 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\` |
|
||||
|
||||
#### 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\` |
|
||||
|
||||
#### 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\` |
|
||||
|
||||
### 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
|
||||
|
||||
To use Claude via AWS Bedrock instead of Anthropic's API, switch the backend to **Bedrock** on the project card.
|
||||
|
||||
### Authentication Methods
|
||||
|
||||
| Method | Fields | Use Case |
|
||||
|--------|--------|----------|
|
||||
| **Keys** | Access Key ID, Secret Access Key, Session Token (optional) | Direct credentials — simplest setup |
|
||||
| **Profile** | AWS Profile name | Uses \`~/.aws/config\` and \`~/.aws/credentials\` on the host |
|
||||
| **Token** | Bearer Token | Temporary bearer token authentication |
|
||||
|
||||
### Additional Bedrock Settings
|
||||
|
||||
- **AWS Region** — Required. The region where your Bedrock models are deployed (e.g., \`us-east-1\`).
|
||||
- **Model ID** — Optional. Override the default Claude model (e.g., \`anthropic.claude-sonnet-4-20250514-v1:0\`).
|
||||
|
||||
### Global AWS Defaults
|
||||
|
||||
In **Settings > AWS Configuration**, you can set defaults that apply to all Bedrock projects:
|
||||
|
||||
- **AWS Config Path** — Path to your \`~/.aws\` directory. Click **Detect** to auto-find it.
|
||||
- **Default Profile** — Select from profiles found in your AWS config.
|
||||
- **Default Region** — Fallback region for projects that don't specify one.
|
||||
|
||||
Per-project settings always override these global defaults.
|
||||
|
||||
---
|
||||
|
||||
## Ollama Configuration
|
||||
|
||||
To use Claude Code with a local or remote Ollama server, switch the backend to **Ollama** on the project card.
|
||||
|
||||
### Settings
|
||||
|
||||
- **Base URL** — The URL of your Ollama server. Defaults to \`http://host.docker.internal:11434\`, which reaches a locally running Ollama instance from inside the container. For a remote server, use its IP or hostname (e.g., \`http://192.168.1.100:11434\`).
|
||||
- **Model ID** — Optional. Override the model to use (e.g., \`qwen3.5:27b\`).
|
||||
|
||||
### How It Works
|
||||
|
||||
Triple-C sets \`ANTHROPIC_BASE_URL\` to point Claude Code at your Ollama server instead of Anthropic's API. The \`ANTHROPIC_AUTH_TOKEN\` is set to \`ollama\` (required by Claude Code but not used for actual authentication).
|
||||
|
||||
> **Note:** Ollama support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models.
|
||||
|
||||
---
|
||||
|
||||
## LiteLLM Configuration
|
||||
|
||||
To use Claude Code through a LiteLLM proxy gateway, switch the backend to **LiteLLM** on the project card. LiteLLM supports 100+ model providers (OpenAI, Gemini, Anthropic, and more) through a single proxy.
|
||||
|
||||
### Settings
|
||||
|
||||
- **Base URL** — The URL of your LiteLLM proxy. Defaults to \`http://host.docker.internal:4000\` for a locally running proxy.
|
||||
- **API Key** — Optional. The API key for your LiteLLM proxy, if authentication is required. Stored securely in your OS keychain.
|
||||
- **Model ID** — Optional. Override the model to use.
|
||||
|
||||
### How It Works
|
||||
|
||||
Triple-C sets \`ANTHROPIC_BASE_URL\` to point Claude Code at your LiteLLM proxy. If an API key is provided, it is set as \`ANTHROPIC_AUTH_TOKEN\`.
|
||||
|
||||
> **Note:** LiteLLM support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected when routing to non-Anthropic models through the proxy.
|
||||
|
||||
---
|
||||
|
||||
## Settings
|
||||
|
||||
Access global settings via the **Settings** tab in the sidebar.
|
||||
|
||||
### Docker Settings
|
||||
|
||||
- **Docker Status** — Connection status to the Docker daemon.
|
||||
- **Image Source** — Where to get the sandbox container image (Registry, Local Build, or Custom).
|
||||
- **Pull / Build Image** — Download or build the image. Progress streams in real time.
|
||||
- **Refresh** — Re-check Docker and image status.
|
||||
|
||||
### Container Timezone
|
||||
|
||||
Set the timezone for all containers (IANA format, e.g., \`America/New_York\`, \`Europe/London\`, \`UTC\`). Auto-detected from your host on first launch. This affects scheduled task timing inside containers.
|
||||
|
||||
### Global Claude Instructions
|
||||
|
||||
Instructions applied to **all** projects. Written to \`~/.claude/CLAUDE.md\` in every container, before any per-project instructions.
|
||||
|
||||
### Global Environment Variables
|
||||
|
||||
Environment variables applied to **all** project containers. Per-project variables with the same key take precedence.
|
||||
|
||||
### Updates
|
||||
|
||||
- **Current Version** — The installed version of Triple-C.
|
||||
- **Auto-check** — Toggle automatic update checks (every 24 hours).
|
||||
- **Check now** — Manually check for updates.
|
||||
|
||||
When an update is available, a pulsing **Update** button appears in the top bar. Click it to see release notes and download links.
|
||||
|
||||
---
|
||||
|
||||
## Terminal Features
|
||||
|
||||
### 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. 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
|
||||
|
||||
When Claude Code prints a long URL (e.g., during \`claude login\`), Triple-C detects it and shows a toast notification at the top of the terminal with an **Open** button. Clicking it opens the URL in your default browser. The toast auto-dismisses after 30 seconds.
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
The terminal uses WebGL for hardware-accelerated rendering of the active tab. Inactive tabs fall back to canvas rendering to conserve GPU resources. The terminal automatically resizes when you resize the window.
|
||||
|
||||
---
|
||||
|
||||
## Scheduled Tasks (Inside the Container)
|
||||
|
||||
Once inside a running container terminal, you can set up recurring or one-time tasks using \`triple-c-scheduler\`. Tasks run as separate Claude Code sessions.
|
||||
|
||||
### Create a Recurring Task
|
||||
|
||||
\`\`\`bash
|
||||
triple-c-scheduler add --name "daily-review" --schedule "0 9 * * *" --prompt "Review open issues and summarize"
|
||||
\`\`\`
|
||||
|
||||
### Create a One-Time Task
|
||||
|
||||
\`\`\`bash
|
||||
triple-c-scheduler add --name "migrate-db" --at "2026-03-05 14:00" --prompt "Run database migrations"
|
||||
\`\`\`
|
||||
|
||||
One-time tasks automatically remove themselves after execution.
|
||||
|
||||
### Manage Tasks
|
||||
|
||||
\`\`\`bash
|
||||
triple-c-scheduler list # List all tasks
|
||||
triple-c-scheduler enable --id abc123 # Enable a task
|
||||
triple-c-scheduler disable --id abc123 # Disable a task
|
||||
triple-c-scheduler remove --id abc123 # Delete a task
|
||||
triple-c-scheduler run --id abc123 # Trigger a task immediately
|
||||
triple-c-scheduler logs --id abc123 # View logs for a task
|
||||
triple-c-scheduler logs --tail 20 # View last 20 log entries (all tasks)
|
||||
triple-c-scheduler notifications # View completion notifications
|
||||
triple-c-scheduler notifications --clear # Clear notifications
|
||||
\`\`\`
|
||||
|
||||
### Cron Schedule Format
|
||||
|
||||
Standard 5-field cron: \`minute hour day-of-month month day-of-week\`
|
||||
|
||||
| Example | Meaning |
|
||||
|---------|---------|
|
||||
| \`*/30 * * * *\` | Every 30 minutes |
|
||||
| \`0 9 * * 1-5\` | 9:00 AM on weekdays |
|
||||
| \`0 */2 * * *\` | Every 2 hours |
|
||||
| \`0 0 1 * *\` | Midnight on the 1st of each month |
|
||||
|
||||
### Working Directory
|
||||
|
||||
By default, tasks run in \`/workspace\`. Use \`--working-dir\` to specify a different directory:
|
||||
|
||||
\`\`\`bash
|
||||
triple-c-scheduler add --name "test" --schedule "0 */6 * * *" --prompt "Run tests" --working-dir /workspace/my-project
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
## What's Inside the Container
|
||||
|
||||
The sandbox container (Ubuntu 24.04) comes pre-installed with:
|
||||
|
||||
| Tool | Version | Purpose |
|
||||
|------|---------|---------|
|
||||
| Claude Code | Latest | AI coding assistant (the tool being sandboxed) |
|
||||
| Node.js | 22 LTS | JavaScript/TypeScript development |
|
||||
| pnpm | Latest | Fast Node.js package manager |
|
||||
| Python | 3.12 | Python development |
|
||||
| uv | Latest | Fast Python package manager |
|
||||
| ruff | Latest | Python linter/formatter |
|
||||
| Rust | Stable | Rust development (via rustup) |
|
||||
| Docker CLI | Latest | Container management (when spawning is enabled) |
|
||||
| git | Latest | Version control |
|
||||
| GitHub CLI (gh) | Latest | GitHub integration |
|
||||
| AWS CLI | v2 | AWS services and Bedrock |
|
||||
| ripgrep | Latest | Fast code search |
|
||||
| build-essential | — | C/C++ compiler toolchain |
|
||||
| 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).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Docker is "Not Available"
|
||||
|
||||
- **Is Docker running?** Start Docker Desktop or the Docker daemon (\`sudo systemctl start docker\`).
|
||||
- **Permissions?** On Linux, ensure your user is in the \`docker\` group or the socket is accessible.
|
||||
- **Custom socket path?** If your Docker socket is not at the default location, set it in Settings. The app expects \`/var/run/docker.sock\` on Linux/macOS or \`//./pipe/docker_engine\` on Windows.
|
||||
|
||||
### Image is "Not Found"
|
||||
|
||||
- Click **Pull Image** or **Build Image** in Settings > Docker.
|
||||
- If pulling fails, check your network connection and whether you can reach the registry.
|
||||
- Try switching to **Local Build** as an alternative.
|
||||
|
||||
### Container Won't Start
|
||||
|
||||
- Check that the Docker image is "Ready" in Settings.
|
||||
- Verify that the mounted folder paths exist on your host.
|
||||
- Look at the error message displayed in the progress modal.
|
||||
|
||||
### OAuth Login URL Not Opening
|
||||
|
||||
- Triple-C detects long URLs printed by \`claude login\` and shows a toast with an **Open** button.
|
||||
- If the toast doesn't appear, try scrolling up in the terminal — the URL may have already been printed.
|
||||
- You can also manually copy the URL from the terminal output and paste it into your browser.
|
||||
|
||||
### File Permission Issues
|
||||
|
||||
- Triple-C automatically remaps the container user's UID/GID to match your host user, so files created inside the container should have the correct ownership on your host.
|
||||
- If you see permission errors, try resetting the container (stop, then click **Reset**).
|
||||
|
||||
### 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.
|
||||
- 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.`;
|
||||
|
||||
/** Simple markdown-to-HTML converter for the help content. */
|
||||
function renderMarkdown(md: string): string {
|
||||
let html = md;
|
||||
|
||||
// Normalize line endings
|
||||
html = html.replace(/\r\n/g, "\n");
|
||||
|
||||
// Escape HTML entities (but we'll re-introduce tags below)
|
||||
html = html.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
// Fenced code blocks (```...```)
|
||||
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
||||
return `<pre class="help-code-block"><code>${code.trimEnd()}</code></pre>`;
|
||||
});
|
||||
|
||||
// Inline code (`...`)
|
||||
html = html.replace(/`([^`]+)`/g, '<code class="help-inline-code">$1</code>');
|
||||
|
||||
// Tables
|
||||
html = html.replace(
|
||||
/(?:^|\n)(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)+)/g,
|
||||
(_m, headerRow: string, _sep: string, bodyRows: string) => {
|
||||
const headers = headerRow
|
||||
.split("|")
|
||||
.slice(1, -1)
|
||||
.map((c: string) => `<th>${c.trim()}</th>`)
|
||||
.join("");
|
||||
const rows = bodyRows
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((row: string) => {
|
||||
const cells = row
|
||||
.split("|")
|
||||
.slice(1, -1)
|
||||
.map((c: string) => `<td>${c.trim()}</td>`)
|
||||
.join("");
|
||||
return `<tr>${cells}</tr>`;
|
||||
})
|
||||
.join("");
|
||||
return `<table class="help-table"><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`;
|
||||
},
|
||||
);
|
||||
|
||||
// Blockquotes (> ...)
|
||||
html = html.replace(/(?:^|\n)> (.+)/g, '<blockquote class="help-blockquote">$1</blockquote>');
|
||||
// Merge adjacent blockquotes
|
||||
html = html.replace(/<\/blockquote>\s*<blockquote class="help-blockquote">/g, "<br/>");
|
||||
|
||||
// Horizontal rules
|
||||
html = html.replace(/\n---\n/g, '<hr class="help-hr"/>');
|
||||
|
||||
// Headers (process from h4 down to h1)
|
||||
html = html.replace(/^#### (.+)$/gm, '<h4 class="help-h4">$1</h4>');
|
||||
html = html.replace(/^### (.+)$/gm, '<h3 class="help-h3">$1</h3>');
|
||||
html = html.replace(/^## (.+)$/gm, '<h2 class="help-h2">$1</h2>');
|
||||
html = html.replace(/^# (.+)$/gm, '<h1 class="help-h1">$1</h1>');
|
||||
|
||||
// Bold (**...**)
|
||||
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
|
||||
|
||||
// Italic (*...*)
|
||||
html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
|
||||
// Unordered list items (- ...)
|
||||
// Group consecutive list items
|
||||
html = html.replace(/((?:^|\n)- .+(?:\n- .+)*)/g, (block) => {
|
||||
const items = block
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => `<li>${line.replace(/^- /, "")}</li>`)
|
||||
.join("");
|
||||
return `<ul class="help-ul">${items}</ul>`;
|
||||
});
|
||||
|
||||
// Ordered list items (1. ...)
|
||||
html = html.replace(/((?:^|\n)\d+\. .+(?:\n\d+\. .+)*)/g, (block) => {
|
||||
const items = block
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => `<li>${line.replace(/^\d+\. /, "")}</li>`)
|
||||
.join("");
|
||||
return `<ol class="help-ol">${items}</ol>`;
|
||||
});
|
||||
|
||||
// Links - convert URLs to clickable links
|
||||
html = html.replace(
|
||||
/(?<!="|'>)(https?:\/\/[^\s<)]+)/g,
|
||||
'<a class="help-link" href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||
);
|
||||
|
||||
// Wrap remaining loose text lines in paragraphs
|
||||
// Split by double newlines for paragraph breaks
|
||||
const blocks = html.split(/\n\n+/);
|
||||
html = blocks
|
||||
.map((block) => {
|
||||
const trimmed = block.trim();
|
||||
if (!trimmed) return "";
|
||||
// Don't wrap blocks that are already HTML elements
|
||||
if (
|
||||
/^<(h[1-4]|ul|ol|pre|table|blockquote|hr)/.test(trimmed)
|
||||
) {
|
||||
return trimmed;
|
||||
}
|
||||
// Wrap in paragraph, replacing single newlines with <br/>
|
||||
return `<p class="help-p">${trimmed.replace(/\n/g, "<br/>")}</p>`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
export default function HelpDialog({ onClose }: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
const renderedHtml = renderMarkdown(HELP_MARKDOWN);
|
||||
|
||||
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-[48rem] max-w-[90vw] max-h-[85vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--border-color)] flex-shrink-0">
|
||||
<h2 className="text-lg font-semibold">How to Use Triple-C</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable content */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto px-6 py-4 help-content"
|
||||
dangerouslySetInnerHTML={{ __html: renderedHtml }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,19 +4,25 @@ import TerminalTabs from "../terminal/TerminalTabs";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import UpdateDialog from "../settings/UpdateDialog";
|
||||
import ImageUpdateDialog from "../settings/ImageUpdateDialog";
|
||||
import HelpDialog from "./HelpDialog";
|
||||
|
||||
export default function TopBar() {
|
||||
const { dockerAvailable, imageExists, updateInfo, appVersion, setUpdateInfo } = useAppState(
|
||||
const { dockerAvailable, imageExists, updateInfo, imageUpdateInfo, appVersion, setUpdateInfo, setImageUpdateInfo } = useAppState(
|
||||
useShallow(s => ({
|
||||
dockerAvailable: s.dockerAvailable,
|
||||
imageExists: s.imageExists,
|
||||
updateInfo: s.updateInfo,
|
||||
imageUpdateInfo: s.imageUpdateInfo,
|
||||
appVersion: s.appVersion,
|
||||
setUpdateInfo: s.setUpdateInfo,
|
||||
setImageUpdateInfo: s.setImageUpdateInfo,
|
||||
}))
|
||||
);
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
|
||||
const [showImageUpdateDialog, setShowImageUpdateDialog] = useState(false);
|
||||
const [showHelpDialog, setShowHelpDialog] = useState(false);
|
||||
|
||||
const handleDismiss = async () => {
|
||||
if (appSettings && updateInfo) {
|
||||
@@ -29,6 +35,17 @@ export default function TopBar() {
|
||||
setShowUpdateDialog(false);
|
||||
};
|
||||
|
||||
const handleImageUpdateDismiss = async () => {
|
||||
if (appSettings && imageUpdateInfo) {
|
||||
await saveSettings({
|
||||
...appSettings,
|
||||
dismissed_image_digest: imageUpdateInfo.remote_digest,
|
||||
});
|
||||
}
|
||||
setImageUpdateInfo(null);
|
||||
setShowImageUpdateDialog(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
|
||||
@@ -44,8 +61,24 @@ export default function TopBar() {
|
||||
Update
|
||||
</button>
|
||||
)}
|
||||
{imageUpdateInfo && (
|
||||
<button
|
||||
onClick={() => setShowImageUpdateDialog(true)}
|
||||
className="px-2 py-0.5 rounded text-xs font-medium bg-[var(--warning,#f59e0b)] text-white hover:opacity-80 transition-colors"
|
||||
title="A newer container image is available"
|
||||
>
|
||||
Image Update
|
||||
</button>
|
||||
)}
|
||||
<StatusDot ok={dockerAvailable === true} label="Docker" />
|
||||
<StatusDot ok={imageExists === true} label="Image" />
|
||||
<button
|
||||
onClick={() => setShowHelpDialog(true)}
|
||||
title="Help"
|
||||
className="ml-1 w-5 h-5 flex items-center justify-center rounded-full border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] transition-colors text-xs font-semibold leading-none"
|
||||
>
|
||||
?
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{showUpdateDialog && updateInfo && (
|
||||
@@ -56,6 +89,16 @@ export default function TopBar() {
|
||||
onClose={() => setShowUpdateDialog(false)}
|
||||
/>
|
||||
)}
|
||||
{showImageUpdateDialog && imageUpdateInfo && (
|
||||
<ImageUpdateDialog
|
||||
imageUpdateInfo={imageUpdateInfo}
|
||||
onDismiss={handleImageUpdateDismiss}
|
||||
onClose={() => setShowImageUpdateDialog(false)}
|
||||
/>
|
||||
)}
|
||||
{showHelpDialog && (
|
||||
<HelpDialog onClose={() => setShowHelpDialog(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
|
||||
className={inputCls}
|
||||
/>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@@ -171,6 +171,14 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
|
||||
</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) */}
|
||||
{transportType === "http" && isDocker && (
|
||||
<div>
|
||||
@@ -183,7 +191,7 @@ export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
|
||||
className={inputCls}
|
||||
/>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -57,7 +57,7 @@ const mockProject: Project = {
|
||||
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
||||
container_id: null,
|
||||
status: "stopped",
|
||||
auth_mode: "anthropic",
|
||||
backend: "anthropic",
|
||||
bedrock_config: null,
|
||||
allow_docker_access: false,
|
||||
ssh_key_path: null,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import type { Project, ProjectPath, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
|
||||
import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, LiteLlmConfig } from "../../lib/types";
|
||||
import { useProjects } from "../../hooks/useProjects";
|
||||
import { useMcpServers } from "../../hooks/useMcpServers";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
@@ -10,6 +10,9 @@ import EnvVarsModal from "./EnvVarsModal";
|
||||
import PortMappingsModal from "./PortMappingsModal";
|
||||
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
|
||||
import ContainerProgressModal from "./ContainerProgressModal";
|
||||
import FileManagerModal from "./FileManagerModal";
|
||||
import ConfirmRemoveModal from "./ConfirmRemoveModal";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
|
||||
interface Props {
|
||||
project: Project;
|
||||
@@ -27,9 +30,13 @@ export default function ProjectCard({ project }: Props) {
|
||||
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
|
||||
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
|
||||
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
|
||||
const [showFileManager, setShowFileManager] = useState(false);
|
||||
const [progressMsg, setProgressMsg] = useState<string | null>(null);
|
||||
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
|
||||
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 isStopped = project.status === "stopped" || project.status === "error";
|
||||
|
||||
@@ -52,8 +59,18 @@ export default function ProjectCard({ project }: Props) {
|
||||
const [bedrockBearerToken, setBedrockBearerToken] = useState(project.bedrock_config?.aws_bearer_token ?? "");
|
||||
const [bedrockModelId, setBedrockModelId] = useState(project.bedrock_config?.model_id ?? "");
|
||||
|
||||
// Ollama local state
|
||||
const [ollamaBaseUrl, setOllamaBaseUrl] = useState(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
|
||||
const [ollamaModelId, setOllamaModelId] = useState(project.ollama_config?.model_id ?? "");
|
||||
|
||||
// LiteLLM local state
|
||||
const [litellmBaseUrl, setLitellmBaseUrl] = useState(project.litellm_config?.base_url ?? "http://host.docker.internal:4000");
|
||||
const [litellmApiKey, setLitellmApiKey] = useState(project.litellm_config?.api_key ?? "");
|
||||
const [litellmModelId, setLitellmModelId] = useState(project.litellm_config?.model_id ?? "");
|
||||
|
||||
// Sync local state when project prop changes (e.g., after save or external update)
|
||||
useEffect(() => {
|
||||
setEditName(project.name);
|
||||
setPaths(project.paths ?? []);
|
||||
setSshKeyPath(project.ssh_key_path ?? "");
|
||||
setGitName(project.git_user_name ?? "");
|
||||
@@ -69,6 +86,11 @@ export default function ProjectCard({ project }: Props) {
|
||||
setBedrockProfile(project.bedrock_config?.aws_profile ?? "");
|
||||
setBedrockBearerToken(project.bedrock_config?.aws_bearer_token ?? "");
|
||||
setBedrockModelId(project.bedrock_config?.model_id ?? "");
|
||||
setOllamaBaseUrl(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
|
||||
setOllamaModelId(project.ollama_config?.model_id ?? "");
|
||||
setLitellmBaseUrl(project.litellm_config?.base_url ?? "http://host.docker.internal:4000");
|
||||
setLitellmApiKey(project.litellm_config?.api_key ?? "");
|
||||
setLitellmModelId(project.litellm_config?.model_id ?? "");
|
||||
}, [project]);
|
||||
|
||||
// Listen for container progress events
|
||||
@@ -135,6 +157,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 () => {
|
||||
try {
|
||||
await stop(project.id);
|
||||
@@ -162,12 +192,29 @@ export default function ProjectCard({ project }: Props) {
|
||||
disable_prompt_caching: false,
|
||||
};
|
||||
|
||||
const handleAuthModeChange = async (mode: AuthMode) => {
|
||||
const defaultOllamaConfig: OllamaConfig = {
|
||||
base_url: "http://host.docker.internal:11434",
|
||||
model_id: null,
|
||||
};
|
||||
|
||||
const defaultLiteLlmConfig: LiteLlmConfig = {
|
||||
base_url: "http://host.docker.internal:4000",
|
||||
api_key: null,
|
||||
model_id: null,
|
||||
};
|
||||
|
||||
const handleBackendChange = async (mode: Backend) => {
|
||||
try {
|
||||
const updates: Partial<Project> = { auth_mode: mode };
|
||||
const updates: Partial<Project> = { backend: mode };
|
||||
if (mode === "bedrock" && !project.bedrock_config) {
|
||||
updates.bedrock_config = defaultBedrockConfig;
|
||||
}
|
||||
if (mode === "ollama" && !project.ollama_config) {
|
||||
updates.ollama_config = defaultOllamaConfig;
|
||||
}
|
||||
if (mode === "lite_llm" && !project.litellm_config) {
|
||||
updates.litellm_config = defaultLiteLlmConfig;
|
||||
}
|
||||
await update({ ...project, ...updates });
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
@@ -290,6 +337,51 @@ export default function ProjectCard({ project }: Props) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOllamaBaseUrlBlur = async () => {
|
||||
try {
|
||||
const current = project.ollama_config ?? defaultOllamaConfig;
|
||||
await update({ ...project, ollama_config: { ...current, base_url: ollamaBaseUrl } });
|
||||
} catch (err) {
|
||||
console.error("Failed to update Ollama base URL:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOllamaModelIdBlur = async () => {
|
||||
try {
|
||||
const current = project.ollama_config ?? defaultOllamaConfig;
|
||||
await update({ ...project, ollama_config: { ...current, model_id: ollamaModelId || null } });
|
||||
} catch (err) {
|
||||
console.error("Failed to update Ollama model ID:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLitellmBaseUrlBlur = async () => {
|
||||
try {
|
||||
const current = project.litellm_config ?? defaultLiteLlmConfig;
|
||||
await update({ ...project, litellm_config: { ...current, base_url: litellmBaseUrl } });
|
||||
} catch (err) {
|
||||
console.error("Failed to update LiteLLM base URL:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLitellmApiKeyBlur = async () => {
|
||||
try {
|
||||
const current = project.litellm_config ?? defaultLiteLlmConfig;
|
||||
await update({ ...project, litellm_config: { ...current, api_key: litellmApiKey || null } });
|
||||
} catch (err) {
|
||||
console.error("Failed to update LiteLLM API key:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLitellmModelIdBlur = async () => {
|
||||
try {
|
||||
const current = project.litellm_config ?? defaultLiteLlmConfig;
|
||||
await update({ ...project, litellm_config: { ...current, model_id: litellmModelId || null } });
|
||||
} catch (err) {
|
||||
console.error("Failed to update LiteLLM model ID:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor = {
|
||||
stopped: "bg-[var(--text-secondary)]",
|
||||
starting: "bg-[var(--warning)]",
|
||||
@@ -309,7 +401,41 @@ export default function ProjectCard({ project }: Props) {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<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 className="mt-0.5 ml-4 space-y-0.5">
|
||||
{project.paths.map((pp, i) => (
|
||||
@@ -321,31 +447,21 @@ export default function ProjectCard({ project }: Props) {
|
||||
|
||||
{isSelected && (
|
||||
<div className="mt-2 ml-4 space-y-2 min-w-0 overflow-hidden">
|
||||
{/* Auth mode selector */}
|
||||
{/* Backend selector */}
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="text-[var(--text-secondary)] mr-1">Auth:</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("anthropic"); }}
|
||||
<span className="text-[var(--text-secondary)] mr-1">Backend:<Tooltip text="Anthropic = direct Claude API via OAuth. Bedrock = AWS Bedrock. Ollama = local models. LiteLLM = proxy gateway for 100+ providers." /></span>
|
||||
<select
|
||||
value={project.backend}
|
||||
onChange={(e) => { e.stopPropagation(); handleBackendChange(e.target.value as Backend); }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={!isStopped}
|
||||
className={`px-2 py-0.5 rounded transition-colors ${
|
||||
project.auth_mode === "anthropic"
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
||||
} disabled:opacity-50`}
|
||||
className="px-2 py-0.5 rounded bg-[var(--bg-primary)] border border-[var(--border-color)] text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
||||
>
|
||||
Anthropic
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }}
|
||||
disabled={!isStopped}
|
||||
className={`px-2 py-0.5 rounded transition-colors ${
|
||||
project.auth_mode === "bedrock"
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
Bedrock
|
||||
</button>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
<option value="bedrock">Bedrock</option>
|
||||
<option value="ollama">Ollama</option>
|
||||
<option value="lite_llm">LiteLLM</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Action buttons */}
|
||||
@@ -371,6 +487,8 @@ export default function ProjectCard({ project }: Props) {
|
||||
<>
|
||||
<ActionButton onClick={handleStop} disabled={loading} label="Stop" />
|
||||
<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 +504,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
label={showConfig ? "Hide" : "Config"}
|
||||
/>
|
||||
<ActionButton
|
||||
onClick={async () => {
|
||||
if (confirm(`Remove project "${project.name}"?`)) {
|
||||
await remove(project.id);
|
||||
}
|
||||
}}
|
||||
onClick={() => setShowRemoveModal(true)}
|
||||
disabled={loading}
|
||||
label="Remove"
|
||||
danger
|
||||
@@ -496,7 +610,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
|
||||
{/* SSH Key */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">SSH Key Directory<Tooltip text="Path to your .ssh directory. Mounted into the container so Claude can authenticate with Git remotes over SSH." /></label>
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
value={sshKeyPath}
|
||||
@@ -518,7 +632,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
|
||||
{/* Git Name */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Name<Tooltip text="Sets git user.name inside the container for commit authorship." /></label>
|
||||
<input
|
||||
value={gitName}
|
||||
onChange={(e) => setGitName(e.target.value)}
|
||||
@@ -531,7 +645,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
|
||||
{/* Git Email */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git Email<Tooltip text="Sets git user.email inside the container for commit authorship." /></label>
|
||||
<input
|
||||
value={gitEmail}
|
||||
onChange={(e) => setGitEmail(e.target.value)}
|
||||
@@ -544,7 +658,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
|
||||
{/* Git Token (HTTPS) */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Git HTTPS Token<Tooltip text="A personal access token (e.g. GitHub PAT) for HTTPS git operations inside the container." /></label>
|
||||
<input
|
||||
type="password"
|
||||
value={gitToken}
|
||||
@@ -558,7 +672,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
|
||||
{/* Docker access toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning</label>
|
||||
<label className="text-xs text-[var(--text-secondary)]">Allow container spawning<Tooltip text="Mounts the Docker socket so Claude can build and run Docker containers from inside the sandbox." /></label>
|
||||
<button
|
||||
onClick={async () => {
|
||||
try { await update({ ...project, allow_docker_access: !project.allow_docker_access }); } catch (err) {
|
||||
@@ -576,10 +690,32 @@ export default function ProjectCard({ project }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mission Control toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-[var(--text-secondary)]">Mission Control<Tooltip text="Enables a web dashboard for monitoring and managing Claude sessions remotely." /></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 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-[var(--text-secondary)]">
|
||||
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}
|
||||
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}<Tooltip text="Custom env vars injected into this project's container. Useful for API keys or tool configuration." />
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowEnvVarsModal(true)}
|
||||
@@ -592,7 +728,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
{/* Port Mappings */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-[var(--text-secondary)]">
|
||||
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}
|
||||
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}<Tooltip text="Map container ports to host ports so you can access dev servers running inside the container." />
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowPortMappingsModal(true)}
|
||||
@@ -605,7 +741,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
{/* Claude Instructions */}
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-[var(--text-secondary)]">
|
||||
Claude Instructions{claudeInstructions ? " (set)" : ""}
|
||||
Claude Instructions{claudeInstructions ? " (set)" : ""}<Tooltip text="Project-specific instructions written to CLAUDE.md. Guides Claude's behavior for this project." />
|
||||
</label>
|
||||
<button
|
||||
onClick={() => setShowClaudeInstructionsModal(true)}
|
||||
@@ -618,7 +754,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
{/* MCP Servers */}
|
||||
{mcpServers.length > 0 && (
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">MCP Servers</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-1">MCP Servers<Tooltip text="Model Context Protocol servers give Claude access to external tools and data sources." /></label>
|
||||
<div className="space-y-1">
|
||||
{mcpServers.map((server) => {
|
||||
const enabled = project.enabled_mcp_servers.includes(server.id);
|
||||
@@ -659,7 +795,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
)}
|
||||
|
||||
{/* Bedrock config */}
|
||||
{project.auth_mode === "bedrock" && (() => {
|
||||
{project.backend === "bedrock" && (() => {
|
||||
const bc = project.bedrock_config ?? defaultBedrockConfig;
|
||||
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50";
|
||||
return (
|
||||
@@ -669,25 +805,22 @@ export default function ProjectCard({ project }: Props) {
|
||||
{/* Sub-method selector */}
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<span className="text-[var(--text-secondary)] mr-1">Method:</span>
|
||||
{(["static_credentials", "profile", "bearer_token"] as BedrockAuthMethod[]).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => updateBedrockConfig({ auth_method: m })}
|
||||
disabled={!isStopped}
|
||||
className={`px-2 py-0.5 rounded transition-colors ${
|
||||
bc.auth_method === m
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
||||
} disabled:opacity-50`}
|
||||
>
|
||||
{m === "static_credentials" ? "Keys" : m === "profile" ? "Profile" : "Token"}
|
||||
</button>
|
||||
))}
|
||||
<select
|
||||
value={bc.auth_method}
|
||||
onChange={(e) => updateBedrockConfig({ auth_method: e.target.value as BedrockAuthMethod })}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
disabled={!isStopped}
|
||||
className="px-2 py-0.5 rounded bg-[var(--bg-primary)] border border-[var(--border-color)] text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
|
||||
>
|
||||
<option value="static_credentials">Keys</option>
|
||||
<option value="profile">Profile</option>
|
||||
<option value="bearer_token">Token</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* AWS Region (always shown) */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region<Tooltip text="The AWS region where your Bedrock endpoint is available (e.g. us-east-1)." /></label>
|
||||
<input
|
||||
value={bedrockRegion}
|
||||
onChange={(e) => setBedrockRegion(e.target.value)}
|
||||
@@ -702,7 +835,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
{bc.auth_method === "static_credentials" && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID<Tooltip text="Your AWS IAM access key ID for Bedrock API authentication." /></label>
|
||||
<input
|
||||
value={bedrockAccessKeyId}
|
||||
onChange={(e) => setBedrockAccessKeyId(e.target.value)}
|
||||
@@ -713,7 +846,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key<Tooltip text="Your AWS IAM secret key. Stored locally and injected as an env var into the container." /></label>
|
||||
<input
|
||||
type="password"
|
||||
value={bedrockSecretKey}
|
||||
@@ -724,7 +857,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)<Tooltip text="Temporary session token for assumed-role or MFA-based AWS credentials." /></label>
|
||||
<input
|
||||
type="password"
|
||||
value={bedrockSessionToken}
|
||||
@@ -740,7 +873,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
{/* Profile field */}
|
||||
{bc.auth_method === "profile" && (
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile<Tooltip text="Named profile from your AWS config/credentials files (e.g. 'default' or 'prod')." /></label>
|
||||
<input
|
||||
value={bedrockProfile}
|
||||
onChange={(e) => setBedrockProfile(e.target.value)}
|
||||
@@ -755,7 +888,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
{/* Bearer token field */}
|
||||
{bc.auth_method === "bearer_token" && (
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token<Tooltip text="An SSO or identity-center bearer token for Bedrock authentication." /></label>
|
||||
<input
|
||||
type="password"
|
||||
value={bedrockBearerToken}
|
||||
@@ -769,7 +902,7 @@ export default function ProjectCard({ project }: Props) {
|
||||
|
||||
{/* Model override */}
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)</label>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)<Tooltip text="Override the default Bedrock model. Leave blank to use Claude's default." /></label>
|
||||
<input
|
||||
value={bedrockModelId}
|
||||
onChange={(e) => setBedrockModelId(e.target.value)}
|
||||
@@ -782,6 +915,99 @@ export default function ProjectCard({ project }: Props) {
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Ollama config */}
|
||||
{project.backend === "ollama" && (() => {
|
||||
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50";
|
||||
return (
|
||||
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]">
|
||||
<label className="block text-xs font-medium text-[var(--text-primary)]">Ollama</label>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Connect to an Ollama server running locally or on a remote host.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL<Tooltip text="URL of your Ollama server. Use host.docker.internal to reach the host machine from inside the container." /></label>
|
||||
<input
|
||||
value={ollamaBaseUrl}
|
||||
onChange={(e) => setOllamaBaseUrl(e.target.value)}
|
||||
onBlur={handleOllamaBaseUrlBlur}
|
||||
placeholder="http://host.docker.internal:11434"
|
||||
disabled={!isStopped}
|
||||
className={inputCls}
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-70">
|
||||
Use host.docker.internal for the host machine, or an IP/hostname for remote.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (optional)<Tooltip text="Ollama model name to use (e.g. qwen3.5:27b). Leave blank for the server default." /></label>
|
||||
<input
|
||||
value={ollamaModelId}
|
||||
onChange={(e) => setOllamaModelId(e.target.value)}
|
||||
onBlur={handleOllamaModelIdBlur}
|
||||
placeholder="qwen3.5:27b"
|
||||
disabled={!isStopped}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* LiteLLM config */}
|
||||
{project.backend === "lite_llm" && (() => {
|
||||
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50";
|
||||
return (
|
||||
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]">
|
||||
<label className="block text-xs font-medium text-[var(--text-primary)]">LiteLLM Gateway</label>
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
Connect through a LiteLLM proxy to use 100+ model providers.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Base URL<Tooltip text="URL of your LiteLLM proxy server. Use host.docker.internal for a locally running proxy." /></label>
|
||||
<input
|
||||
value={litellmBaseUrl}
|
||||
onChange={(e) => setLitellmBaseUrl(e.target.value)}
|
||||
onBlur={handleLitellmBaseUrlBlur}
|
||||
placeholder="http://host.docker.internal:4000"
|
||||
disabled={!isStopped}
|
||||
className={inputCls}
|
||||
/>
|
||||
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-70">
|
||||
Use host.docker.internal for local, or a URL for remote/containerized LiteLLM.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">API Key<Tooltip text="Authentication key for your LiteLLM proxy, if required." /></label>
|
||||
<input
|
||||
type="password"
|
||||
value={litellmApiKey}
|
||||
onChange={(e) => setLitellmApiKey(e.target.value)}
|
||||
onBlur={handleLitellmApiKeyBlur}
|
||||
placeholder="sk-..."
|
||||
disabled={!isStopped}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model (optional)<Tooltip text="Model identifier as configured in your LiteLLM proxy (e.g. gpt-4o, gemini-pro)." /></label>
|
||||
<input
|
||||
value={litellmModelId}
|
||||
onChange={(e) => setLitellmModelId(e.target.value)}
|
||||
onBlur={handleLitellmModelIdBlur}
|
||||
placeholder="gpt-4o / gemini-pro / etc."
|
||||
disabled={!isStopped}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -827,6 +1053,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 && (
|
||||
<ContainerProgressModal
|
||||
projectName={project.name}
|
||||
@@ -869,3 +1114,4 @@ function ActionButton({
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
export default function ApiKeyInput() {
|
||||
return (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Authentication</label>
|
||||
<label className="block text-sm font-medium mb-1">Backend</label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
||||
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal) or <strong>AWS Bedrock</strong>. Set auth mode per-project.
|
||||
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal) or <strong>AWS Bedrock</strong>. Set backend per-project.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import * as commands from "../../lib/tauri-commands";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
|
||||
export default function AwsSettings() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
@@ -56,7 +57,7 @@ export default function AwsSettings() {
|
||||
|
||||
{/* AWS Config Path */}
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">AWS Config Path</span>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">AWS Config Path<Tooltip text="Path to your AWS config/credentials directory. Mounted into containers for Bedrock auth." /></span>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
@@ -80,7 +81,7 @@ export default function AwsSettings() {
|
||||
|
||||
{/* AWS Profile */}
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Profile</span>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Profile<Tooltip text="AWS named profile to use by default. Per-project settings can override this." /></span>
|
||||
<select
|
||||
value={globalAws.aws_profile ?? ""}
|
||||
onChange={(e) => handleChange("aws_profile", e.target.value)}
|
||||
@@ -95,7 +96,7 @@ export default function AwsSettings() {
|
||||
|
||||
{/* AWS Region */}
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Region</span>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Default Region<Tooltip text="Default AWS region for Bedrock API calls (e.g. us-east-1). Can be overridden per project." /></span>
|
||||
<input
|
||||
type="text"
|
||||
value={globalAws.aws_region ?? ""}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useState } from "react";
|
||||
import { useDocker } from "../../hooks/useDocker";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import type { ImageSource } from "../../lib/types";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
|
||||
const REGISTRY_IMAGE = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest";
|
||||
|
||||
@@ -87,7 +88,7 @@ export default function DockerSettings() {
|
||||
|
||||
{/* Image Source Selector */}
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1.5">Image Source</span>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1.5">Image Source<Tooltip text="Registry pulls the pre-built image. Local Build compiles from the bundled Dockerfile. Custom lets you specify any image." /></span>
|
||||
<div className="flex gap-1">
|
||||
{IMAGE_SOURCE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
@@ -109,7 +110,7 @@ export default function DockerSettings() {
|
||||
{/* Custom image input */}
|
||||
{imageSource === "custom" && (
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Custom Image</span>
|
||||
<span className="text-[var(--text-secondary)] text-xs block mb-1">Custom Image<Tooltip text="Full image name including registry and tag (e.g. myregistry.com/image:tag)." /></span>
|
||||
<input
|
||||
type="text"
|
||||
value={customInput}
|
||||
@@ -121,9 +122,9 @@ export default function DockerSettings() {
|
||||
)}
|
||||
|
||||
{/* Resolved image display */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-[var(--text-secondary)]">Image</span>
|
||||
<span className="text-xs text-[var(--text-secondary)] truncate max-w-[200px]" title={resolvedImageName}>
|
||||
<span className="block text-xs text-[var(--text-secondary)] font-mono mt-0.5 truncate" title={resolvedImageName}>
|
||||
{resolvedImageName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
91
app/src/components/settings/ImageUpdateDialog.tsx
Normal file
91
app/src/components/settings/ImageUpdateDialog.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import type { ImageUpdateInfo } from "../../lib/types";
|
||||
|
||||
interface Props {
|
||||
imageUpdateInfo: ImageUpdateInfo;
|
||||
onDismiss: () => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ImageUpdateDialog({
|
||||
imageUpdateInfo,
|
||||
onDismiss,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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],
|
||||
);
|
||||
|
||||
const shortDigest = (digest: string) => {
|
||||
// Show first 16 chars of the hash part (after "sha256:")
|
||||
const hash = digest.startsWith("sha256:") ? digest.slice(7) : digest;
|
||||
return hash.slice(0, 16);
|
||||
};
|
||||
|
||||
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-[28rem] max-h-[80vh] overflow-y-auto shadow-xl">
|
||||
<h2 className="text-lg font-semibold mb-3">Container Image Update</h2>
|
||||
|
||||
<p className="text-sm text-[var(--text-secondary)] mb-4">
|
||||
A newer version of the container image is available in the registry.
|
||||
Re-pull the image in Docker settings to get the latest tools and fixes.
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 mb-4 text-xs bg-[var(--bg-primary)] rounded p-3 border border-[var(--border-color)]">
|
||||
{imageUpdateInfo.local_digest && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--text-secondary)]">Local digest</span>
|
||||
<span className="font-mono text-[var(--text-primary)]">
|
||||
{shortDigest(imageUpdateInfo.local_digest)}...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between">
|
||||
<span className="text-[var(--text-secondary)]">Remote digest</span>
|
||||
<span className="font-mono text-[var(--accent)]">
|
||||
{shortDigest(imageUpdateInfo.remote_digest)}...
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-4">
|
||||
Go to Settings > Docker and click "Re-pull Image" to update.
|
||||
Running containers will not be affected until restarted.
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={onDismiss}
|
||||
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import ApiKeyInput from "./ApiKeyInput";
|
||||
import DockerSettings from "./DockerSettings";
|
||||
import AwsSettings from "./AwsSettings";
|
||||
import MicrophoneSettings from "./MicrophoneSettings";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import { useUpdates } from "../../hooks/useUpdates";
|
||||
import ClaudeInstructionsModal from "../projects/ClaudeInstructionsModal";
|
||||
import EnvVarsModal from "../projects/EnvVarsModal";
|
||||
import { detectHostTimezone } from "../../lib/tauri-commands";
|
||||
import type { EnvVar } from "../../lib/types";
|
||||
import Tooltip from "../ui/Tooltip";
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const { appSettings, saveSettings } = useSettings();
|
||||
const { appVersion, checkForUpdates } = useUpdates();
|
||||
const { appVersion, imageUpdateInfo, checkForUpdates, checkImageUpdate } = useUpdates();
|
||||
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
|
||||
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
|
||||
const [checkingUpdates, setCheckingUpdates] = useState(false);
|
||||
@@ -40,7 +39,7 @@ export default function SettingsPanel() {
|
||||
const handleCheckNow = async () => {
|
||||
setCheckingUpdates(true);
|
||||
try {
|
||||
await checkForUpdates();
|
||||
await Promise.all([checkForUpdates(), checkImageUpdate()]);
|
||||
} finally {
|
||||
setCheckingUpdates(false);
|
||||
}
|
||||
@@ -56,15 +55,12 @@ export default function SettingsPanel() {
|
||||
<h2 className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
|
||||
Settings
|
||||
</h2>
|
||||
<ApiKeyInput />
|
||||
<DockerSettings />
|
||||
<AwsSettings />
|
||||
|
||||
<MicrophoneSettings />
|
||||
|
||||
{/* Container Timezone */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Container Timezone</label>
|
||||
<label className="block text-sm font-medium mb-1">Container Timezone<Tooltip text="Sets the timezone inside containers. Affects scheduled task timing and log timestamps." /></label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||
Timezone for containers — affects scheduled task timing (IANA format, e.g. America/New_York)
|
||||
</p>
|
||||
@@ -84,7 +80,7 @@ export default function SettingsPanel() {
|
||||
|
||||
{/* Global Claude Instructions */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Claude Instructions</label>
|
||||
<label className="block text-sm font-medium mb-1">Claude Instructions<Tooltip text="Global instructions applied to all projects. Written to ~/.claude/CLAUDE.md in every container." /></label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||
Global instructions applied to all projects (written to ~/.claude/CLAUDE.md in containers)
|
||||
</p>
|
||||
@@ -103,7 +99,7 @@ export default function SettingsPanel() {
|
||||
|
||||
{/* Global Environment Variables */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1">Global Environment Variables</label>
|
||||
<label className="block text-sm font-medium mb-1">Global Environment Variables<Tooltip text="Env vars injected into all containers. Per-project vars with the same key take precedence." /></label>
|
||||
<p className="text-xs text-[var(--text-secondary)] mb-1.5">
|
||||
Applied to all project containers. Per-project variables override global ones with the same key.
|
||||
</p>
|
||||
@@ -122,7 +118,7 @@ export default function SettingsPanel() {
|
||||
|
||||
{/* Updates section */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">Updates</label>
|
||||
<label className="block text-sm font-medium mb-2">Updates<Tooltip text="Check for new versions of the Triple-C app and container image." /></label>
|
||||
<div className="space-y-2">
|
||||
{appVersion && (
|
||||
<p className="text-xs text-[var(--text-secondary)]">
|
||||
@@ -149,6 +145,12 @@ export default function SettingsPanel() {
|
||||
>
|
||||
{checkingUpdates ? "Checking..." : "Check now"}
|
||||
</button>
|
||||
{imageUpdateInfo && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 text-xs bg-[var(--bg-primary)] border border-[var(--warning,#f59e0b)] rounded">
|
||||
<span className="inline-block w-2 h-2 rounded-full bg-[var(--warning,#f59e0b)]" />
|
||||
<span>A newer container image is available. Re-pull the image in Docker settings above to update.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -23,7 +23,9 @@ export default function TerminalTabs() {
|
||||
: "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
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -6,8 +6,8 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useSettings } from "../../hooks/useSettings";
|
||||
import { useVoice } from "../../hooks/useVoice";
|
||||
import { useAppState } from "../../store/appState";
|
||||
import { awsSsoRefresh } from "../../lib/tauri-commands";
|
||||
import { UrlDetector } from "../../lib/urlDetector";
|
||||
import UrlToast from "./UrlToast";
|
||||
|
||||
@@ -24,9 +24,12 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const webglRef = useRef<WebglAddon | null>(null);
|
||||
const detectorRef = useRef<UrlDetector | null>(null);
|
||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||
const { appSettings } = useSettings();
|
||||
|
||||
const voice = useVoice(sessionId, appSettings?.default_microphone);
|
||||
const ssoBufferRef = useRef("");
|
||||
const ssoTriggeredRef = useRef(false);
|
||||
const projectId = useAppState(
|
||||
(s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId
|
||||
);
|
||||
|
||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||
@@ -157,10 +160,30 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const detector = new UrlDetector((url) => setDetectedUrl(url));
|
||||
detectorRef.current = detector;
|
||||
|
||||
const SSO_MARKER = "###TRIPLE_C_SSO_REFRESH###";
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
const outputPromise = onOutput(sessionId, (data) => {
|
||||
if (aborted) return;
|
||||
term.write(data);
|
||||
detector.feed(data);
|
||||
|
||||
// Scan for SSO refresh marker in terminal output
|
||||
if (!ssoTriggeredRef.current && projectId) {
|
||||
const text = textDecoder.decode(data, { stream: true });
|
||||
// Combine with overlap from previous chunk to handle marker spanning chunks
|
||||
const combined = ssoBufferRef.current + text;
|
||||
if (combined.includes(SSO_MARKER)) {
|
||||
ssoTriggeredRef.current = true;
|
||||
ssoBufferRef.current = "";
|
||||
awsSsoRefresh(projectId).catch((e) =>
|
||||
console.error("AWS SSO refresh failed:", e)
|
||||
);
|
||||
} else {
|
||||
// Keep last N chars as overlap for next chunk
|
||||
ssoBufferRef.current = combined.slice(-SSO_MARKER.length);
|
||||
}
|
||||
}
|
||||
}).then((unlisten) => {
|
||||
if (aborted) unlisten();
|
||||
return unlisten;
|
||||
@@ -194,6 +217,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
aborted = true;
|
||||
detector.dispose();
|
||||
detectorRef.current = null;
|
||||
ssoTriggeredRef.current = false;
|
||||
ssoBufferRef.current = "";
|
||||
osc52Disposable.dispose();
|
||||
inputDisposable.dispose();
|
||||
scrollDisposable.dispose();
|
||||
@@ -205,7 +230,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||||
webglRef.current = null;
|
||||
term.dispose();
|
||||
voice.stop();
|
||||
};
|
||||
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -290,32 +314,6 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
{imagePasteMsg}
|
||||
</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 && (
|
||||
<button
|
||||
onClick={handleScrollToBottom}
|
||||
|
||||
78
app/src/components/ui/Tooltip.tsx
Normal file
78
app/src/components/ui/Tooltip.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useState, useRef, useEffect, type ReactNode } from "react";
|
||||
|
||||
interface TooltipProps {
|
||||
text: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A small circled question-mark icon that shows a tooltip on hover.
|
||||
* Renders inline and automatically repositions to stay within the viewport.
|
||||
*/
|
||||
export default function Tooltip({ text, children }: TooltipProps) {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [position, setPosition] = useState<"top" | "bottom">("top");
|
||||
const [align, setAlign] = useState<"center" | "left" | "right">("center");
|
||||
const triggerRef = useRef<HTMLSpanElement>(null);
|
||||
const tooltipRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || !triggerRef.current || !tooltipRef.current) return;
|
||||
|
||||
const triggerRect = triggerRef.current.getBoundingClientRect();
|
||||
const tooltipRect = tooltipRef.current.getBoundingClientRect();
|
||||
|
||||
// Decide vertical position: prefer top, fall back to bottom
|
||||
if (triggerRect.top - tooltipRect.height - 6 < 4) {
|
||||
setPosition("bottom");
|
||||
} else {
|
||||
setPosition("top");
|
||||
}
|
||||
|
||||
// Decide horizontal alignment
|
||||
const centerLeft = triggerRect.left + triggerRect.width / 2 - tooltipRect.width / 2;
|
||||
const centerRight = centerLeft + tooltipRect.width;
|
||||
if (centerLeft < 4) {
|
||||
setAlign("left");
|
||||
} else if (centerRight > window.innerWidth - 4) {
|
||||
setAlign("right");
|
||||
} else {
|
||||
setAlign("center");
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
const positionClasses = position === "top" ? "bottom-full mb-1.5" : "top-full mt-1.5";
|
||||
|
||||
const alignClasses =
|
||||
align === "left"
|
||||
? "left-0"
|
||||
: align === "right"
|
||||
? "right-0"
|
||||
: "left-1/2 -translate-x-1/2";
|
||||
|
||||
return (
|
||||
<span
|
||||
ref={triggerRef}
|
||||
className="relative inline-flex items-center ml-1"
|
||||
onMouseEnter={() => setVisible(true)}
|
||||
onMouseLeave={() => setVisible(false)}
|
||||
>
|
||||
{children ?? (
|
||||
<span
|
||||
className="inline-flex items-center justify-center w-3.5 h-3.5 rounded-full border border-[var(--text-secondary)] text-[var(--text-secondary)] text-[9px] leading-none cursor-help select-none hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors"
|
||||
aria-label="Help"
|
||||
>
|
||||
?
|
||||
</span>
|
||||
)}
|
||||
{visible && (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className={`absolute z-50 ${positionClasses} ${alignClasses} px-2.5 py-1.5 text-[11px] leading-snug text-[var(--text-primary)] bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded shadow-lg whitespace-normal max-w-[220px] w-max pointer-events-none`}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
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(
|
||||
async (projectId: string, projectName: string) => {
|
||||
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude") => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
await commands.openTerminalSession(projectId, sessionId);
|
||||
addSession({ id: sessionId, projectId, projectName });
|
||||
await commands.openTerminalSession(projectId, sessionId, sessionType);
|
||||
addSession({ id: sessionId, projectId, projectName, sessionType });
|
||||
return sessionId;
|
||||
},
|
||||
[addSession],
|
||||
|
||||
@@ -6,16 +6,25 @@ import * as commands from "../lib/tauri-commands";
|
||||
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
export function useUpdates() {
|
||||
const { updateInfo, setUpdateInfo, appVersion, setAppVersion, appSettings } =
|
||||
useAppState(
|
||||
useShallow((s) => ({
|
||||
updateInfo: s.updateInfo,
|
||||
setUpdateInfo: s.setUpdateInfo,
|
||||
appVersion: s.appVersion,
|
||||
setAppVersion: s.setAppVersion,
|
||||
appSettings: s.appSettings,
|
||||
})),
|
||||
);
|
||||
const {
|
||||
updateInfo,
|
||||
setUpdateInfo,
|
||||
imageUpdateInfo,
|
||||
setImageUpdateInfo,
|
||||
appVersion,
|
||||
setAppVersion,
|
||||
appSettings,
|
||||
} = useAppState(
|
||||
useShallow((s) => ({
|
||||
updateInfo: s.updateInfo,
|
||||
setUpdateInfo: s.setUpdateInfo,
|
||||
imageUpdateInfo: s.imageUpdateInfo,
|
||||
setImageUpdateInfo: s.setImageUpdateInfo,
|
||||
appVersion: s.appVersion,
|
||||
setAppVersion: s.setAppVersion,
|
||||
appSettings: s.appSettings,
|
||||
})),
|
||||
);
|
||||
|
||||
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
@@ -47,11 +56,31 @@ export function useUpdates() {
|
||||
}
|
||||
}, [setUpdateInfo, appSettings?.dismissed_update_version]);
|
||||
|
||||
const checkImageUpdate = useCallback(async () => {
|
||||
try {
|
||||
const info = await commands.checkImageUpdate();
|
||||
if (info) {
|
||||
// Respect dismissed image digest
|
||||
const dismissed = appSettings?.dismissed_image_digest;
|
||||
if (dismissed && dismissed === info.remote_digest) {
|
||||
setImageUpdateInfo(null);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
setImageUpdateInfo(info);
|
||||
return info;
|
||||
} catch (e) {
|
||||
console.error("Failed to check for image updates:", e);
|
||||
return null;
|
||||
}
|
||||
}, [setImageUpdateInfo, appSettings?.dismissed_image_digest]);
|
||||
|
||||
const startPeriodicCheck = useCallback(() => {
|
||||
if (intervalRef.current) return;
|
||||
intervalRef.current = setInterval(() => {
|
||||
if (appSettings?.auto_check_updates !== false) {
|
||||
checkForUpdates();
|
||||
checkImageUpdate();
|
||||
}
|
||||
}, CHECK_INTERVAL_MS);
|
||||
return () => {
|
||||
@@ -60,13 +89,15 @@ export function useUpdates() {
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [checkForUpdates, appSettings?.auto_check_updates]);
|
||||
}, [checkForUpdates, checkImageUpdate, appSettings?.auto_check_updates]);
|
||||
|
||||
return {
|
||||
updateInfo,
|
||||
imageUpdateInfo,
|
||||
appVersion,
|
||||
loadVersion,
|
||||
checkForUpdates,
|
||||
checkImageUpdate,
|
||||
startPeriodicCheck,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,3 +53,135 @@ body {
|
||||
to { opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
.animate-slide-down { animation: slide-down 0.2s ease-out; }
|
||||
|
||||
/* Help dialog content styles */
|
||||
.help-content {
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.help-content .help-h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.help-content .help-h2 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
padding-bottom: 0.375rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.help-content .help-h3 {
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
margin: 1.25rem 0 0.5rem 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.help-content .help-h4 {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 1rem 0 0.375rem 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.help-content .help-p {
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
|
||||
.help-content .help-ul,
|
||||
.help-content .help-ol {
|
||||
margin: 0.5rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.help-content .help-ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.help-content .help-ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.help-content .help-ul li,
|
||||
.help-content .help-ol li {
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.help-content .help-code-block {
|
||||
display: block;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0.5rem 0;
|
||||
overflow-x: auto;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.help-content .help-inline-code {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 0.125rem 0.375rem;
|
||||
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.help-content .help-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.help-content .help-table th,
|
||||
.help-content .help-table td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 0.375rem 0.625rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.help-content .help-table th {
|
||||
background: var(--bg-tertiary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.help-content .help-table td {
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.help-content .help-blockquote {
|
||||
border-left: 3px solid var(--accent);
|
||||
background: var(--bg-primary);
|
||||
margin: 0.5rem 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.help-content .help-hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.help-content .help-link {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.help-content .help-link:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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, ImageUpdateInfo, McpServer, FileEntry } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -24,6 +24,8 @@ export const stopProjectContainer = (projectId: string) =>
|
||||
invoke<void>("stop_project_container", { projectId });
|
||||
export const rebuildProjectContainer = (projectId: string) =>
|
||||
invoke<Project>("rebuild_project_container", { projectId });
|
||||
export const reconcileProjectStatuses = () =>
|
||||
invoke<Project[]>("reconcile_project_statuses");
|
||||
|
||||
// Settings
|
||||
export const getSettings = () => invoke<AppSettings>("get_settings");
|
||||
@@ -38,9 +40,13 @@ export const listAwsProfiles = () =>
|
||||
export const detectHostTimezone = () =>
|
||||
invoke<string>("detect_host_timezone");
|
||||
|
||||
// AWS
|
||||
export const awsSsoRefresh = (projectId: string) =>
|
||||
invoke<void>("aws_sso_refresh", { projectId });
|
||||
|
||||
// Terminal
|
||||
export const openTerminalSession = (projectId: string, sessionId: string) =>
|
||||
invoke<void>("open_terminal_session", { projectId, sessionId });
|
||||
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string) =>
|
||||
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType });
|
||||
export const terminalInput = (sessionId: string, data: number[]) =>
|
||||
invoke<void>("terminal_input", { sessionId, data });
|
||||
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
||||
@@ -65,7 +71,17 @@ export const updateMcpServer = (server: McpServer) =>
|
||||
export const removeMcpServer = (serverId: string) =>
|
||||
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
|
||||
export const getAppVersion = () => invoke<string>("get_app_version");
|
||||
export const checkForUpdates = () =>
|
||||
invoke<UpdateInfo | null>("check_for_updates");
|
||||
export const checkImageUpdate = () =>
|
||||
invoke<ImageUpdateInfo | null>("check_image_update");
|
||||
|
||||
@@ -20,9 +20,12 @@ export interface Project {
|
||||
paths: ProjectPath[];
|
||||
container_id: string | null;
|
||||
status: ProjectStatus;
|
||||
auth_mode: AuthMode;
|
||||
backend: Backend;
|
||||
bedrock_config: BedrockConfig | null;
|
||||
ollama_config: OllamaConfig | null;
|
||||
litellm_config: LiteLlmConfig | null;
|
||||
allow_docker_access: boolean;
|
||||
mission_control_enabled: boolean;
|
||||
ssh_key_path: string | null;
|
||||
git_token: string | null;
|
||||
git_user_name: string | null;
|
||||
@@ -42,7 +45,7 @@ export type ProjectStatus =
|
||||
| "stopping"
|
||||
| "error";
|
||||
|
||||
export type AuthMode = "anthropic" | "bedrock";
|
||||
export type Backend = "anthropic" | "bedrock" | "ollama" | "lite_llm";
|
||||
|
||||
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
|
||||
|
||||
@@ -58,6 +61,17 @@ export interface BedrockConfig {
|
||||
disable_prompt_caching: boolean;
|
||||
}
|
||||
|
||||
export interface OllamaConfig {
|
||||
base_url: string;
|
||||
model_id: string | null;
|
||||
}
|
||||
|
||||
export interface LiteLlmConfig {
|
||||
base_url: string;
|
||||
api_key: string | null;
|
||||
model_id: string | null;
|
||||
}
|
||||
|
||||
export interface ContainerInfo {
|
||||
container_id: string;
|
||||
project_id: string;
|
||||
@@ -77,6 +91,7 @@ export interface TerminalSession {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
sessionType: "claude" | "bash";
|
||||
}
|
||||
|
||||
export type ImageSource = "registry" | "local_build" | "custom";
|
||||
@@ -101,6 +116,7 @@ export interface AppSettings {
|
||||
dismissed_update_version: string | null;
|
||||
timezone: string | null;
|
||||
default_microphone: string | null;
|
||||
dismissed_image_digest: string | null;
|
||||
}
|
||||
|
||||
export interface UpdateInfo {
|
||||
@@ -118,6 +134,12 @@ export interface ReleaseAsset {
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface ImageUpdateInfo {
|
||||
remote_digest: string;
|
||||
local_digest: string | null;
|
||||
remote_updated_at: string | null;
|
||||
}
|
||||
|
||||
export type McpTransportType = "stdio" | "http";
|
||||
|
||||
export interface McpServer {
|
||||
@@ -134,3 +156,12 @@ export interface McpServer {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_directory: boolean;
|
||||
size: number;
|
||||
modified: string;
|
||||
permissions: string;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import type { Project, TerminalSession, AppSettings, UpdateInfo, McpServer } from "../lib/types";
|
||||
import type { Project, TerminalSession, AppSettings, UpdateInfo, ImageUpdateInfo, McpServer } from "../lib/types";
|
||||
|
||||
interface AppState {
|
||||
// Projects
|
||||
@@ -39,6 +39,10 @@ interface AppState {
|
||||
setUpdateInfo: (info: UpdateInfo | null) => void;
|
||||
appVersion: string;
|
||||
setAppVersion: (version: string) => void;
|
||||
|
||||
// Image update info
|
||||
imageUpdateInfo: ImageUpdateInfo | null;
|
||||
setImageUpdateInfo: (info: ImageUpdateInfo | null) => void;
|
||||
}
|
||||
|
||||
export const useAppState = create<AppState>((set) => ({
|
||||
@@ -111,4 +115,8 @@ export const useAppState = create<AppState>((set) => ({
|
||||
setUpdateInfo: (info) => set({ updateInfo: info }),
|
||||
appVersion: "",
|
||||
setAppVersion: (version) => set({ appVersion: version }),
|
||||
|
||||
// Image update info
|
||||
imageUpdateInfo: null,
|
||||
setImageUpdateInfo: (info) => set({ imageUpdateInfo: info }),
|
||||
}));
|
||||
|
||||
@@ -119,6 +119,9 @@ RUN chmod +x /usr/local/bin/audio-shim \
|
||||
&& ln -sf /usr/local/bin/audio-shim /usr/local/bin/rec \
|
||||
&& ln -sf /usr/local/bin/audio-shim /usr/local/bin/arecord
|
||||
|
||||
COPY triple-c-sso-refresh /usr/local/bin/triple-c-sso-refresh
|
||||
RUN chmod +x /usr/local/bin/triple-c-sso-refresh
|
||||
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler
|
||||
|
||||
@@ -84,6 +84,31 @@ if [ -d /tmp/.host-aws ]; then
|
||||
# Ensure writable cache directories exist
|
||||
mkdir -p /home/claude/.aws/sso/cache /home/claude/.aws/cli/cache
|
||||
chown -R claude:claude /home/claude/.aws/sso /home/claude/.aws/cli
|
||||
|
||||
# Inline sso_session properties into profile sections so AWS SDKs that don't
|
||||
# support the sso_session indirection format can resolve sso_region, etc.
|
||||
if [ -f /home/claude/.aws/config ]; then
|
||||
python3 -c '
|
||||
import configparser, sys
|
||||
c = configparser.ConfigParser()
|
||||
c.read(sys.argv[1])
|
||||
for sec in c.sections():
|
||||
if not sec.startswith("profile ") and sec != "default":
|
||||
continue
|
||||
session = c.get(sec, "sso_session", fallback=None)
|
||||
if not session or c.has_option(sec, "sso_start_url"):
|
||||
continue
|
||||
ss = f"sso-session {session}"
|
||||
if not c.has_section(ss):
|
||||
continue
|
||||
for key in ("sso_start_url", "sso_region", "sso_registration_scopes"):
|
||||
val = c.get(ss, key, fallback=None)
|
||||
if val:
|
||||
c.set(sec, key, val)
|
||||
with open(sys.argv[1], "w") as f:
|
||||
c.write(f)
|
||||
' /home/claude/.aws/config 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Git credential helper (for HTTPS token) ─────────────────────────────────
|
||||
@@ -116,6 +141,33 @@ if [ -n "$CLAUDE_INSTRUCTIONS" ]; then
|
||||
unset CLAUDE_INSTRUCTIONS
|
||||
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 ────────────────────────────────────────────────
|
||||
# Merge MCP server config into ~/.claude.json (preserves existing keys like
|
||||
# OAuth tokens). Creates the file if it doesn't exist.
|
||||
@@ -137,6 +189,24 @@ if [ -n "$MCP_SERVERS_JSON" ]; then
|
||||
unset MCP_SERVERS_JSON
|
||||
fi
|
||||
|
||||
# ── AWS SSO auth refresh command ──────────────────────────────────────────────
|
||||
# When set, inject awsAuthRefresh into ~/.claude.json so Claude Code calls
|
||||
# triple-c-sso-refresh when AWS credentials expire mid-session.
|
||||
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
|
||||
CLAUDE_JSON="/home/claude/.claude.json"
|
||||
if [ -f "$CLAUDE_JSON" ]; then
|
||||
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
|
||||
if [ -n "$MERGED" ]; then
|
||||
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
|
||||
fi
|
||||
else
|
||||
printf '{"awsAuthRefresh":"%s"}\n' "$AWS_SSO_AUTH_REFRESH_CMD" > "$CLAUDE_JSON"
|
||||
fi
|
||||
chown claude:claude "$CLAUDE_JSON"
|
||||
chmod 600 "$CLAUDE_JSON"
|
||||
unset AWS_SSO_AUTH_REFRESH_CMD
|
||||
fi
|
||||
|
||||
# ── Docker socket permissions ────────────────────────────────────────────────
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
|
||||
|
||||
33
container/triple-c-sso-refresh
Executable file
33
container/triple-c-sso-refresh
Executable file
@@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# Signal Triple-C to perform host-side AWS SSO login, then sync the result.
|
||||
CACHE_DIR="$HOME/.aws/sso/cache"
|
||||
HOST_CACHE="/tmp/.host-aws/sso/cache"
|
||||
MARKER="/tmp/.sso-refresh-marker"
|
||||
|
||||
touch "$MARKER"
|
||||
|
||||
# Emit marker for Triple-C app to detect in terminal output
|
||||
echo "###TRIPLE_C_SSO_REFRESH###"
|
||||
echo "Waiting for SSO login to complete on host..."
|
||||
|
||||
TIMEOUT=120
|
||||
ELAPSED=0
|
||||
while [ $ELAPSED -lt $TIMEOUT ]; do
|
||||
if [ -d "$HOST_CACHE" ]; then
|
||||
NEW=$(find "$HOST_CACHE" -name "*.json" -newer "$MARKER" 2>/dev/null | head -1)
|
||||
if [ -n "$NEW" ]; then
|
||||
mkdir -p "$CACHE_DIR"
|
||||
cp -f "$HOST_CACHE"/*.json "$CACHE_DIR/" 2>/dev/null
|
||||
chown -R "$(whoami)" "$CACHE_DIR"
|
||||
echo "AWS SSO credentials refreshed successfully."
|
||||
rm -f "$MARKER"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
sleep 2
|
||||
ELAPSED=$((ELAPSED + 2))
|
||||
done
|
||||
|
||||
echo "SSO refresh timed out (${TIMEOUT}s). Please try again."
|
||||
rm -f "$MARKER"
|
||||
exit 1
|
||||
Reference in New Issue
Block a user