Compare commits

..

42 Commits

Author SHA1 Message Date
e07c0e6150 fix: use SHA-256 for container fingerprints instead of DefaultHasher
All checks were successful
Build App / build-macos (push) Successful in 2m23s
Build App / build-windows (push) Successful in 3m17s
Build App / build-linux (push) Successful in 4m30s
Sync Release to GitHub / sync-release (release) Successful in 2s
DefaultHasher is not stable across Rust compiler versions or binary
rebuilds, causing unnecessary container recreations on every app update.
Replace with SHA-256 for deterministic, cross-build-stable fingerprints.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:42:06 -08:00
20a07c84f2 feat: upgrade MCP to Docker-based architecture (Beta)
All checks were successful
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m50s
Build App / build-linux (push) Successful in 5m28s
Sync Release to GitHub / sync-release (release) Successful in 2s
Each MCP server can now run as its own Docker container on a dedicated
per-project bridge network, enabling proper isolation and lifecycle
management. SSE transport is removed (deprecated per MCP spec) with
backward-compatible serde alias. Docker socket access is auto-enabled
when stdio+Docker MCP servers are configured.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 10:21:05 -08:00
625d48a6ed feat: add MCP server support with global library and per-project toggles
All checks were successful
Build App / build-macos (push) Successful in 2m20s
Build App / build-windows (push) Successful in 3m21s
Build App / build-linux (push) Successful in 5m8s
Build Container / build-container (push) Successful in 1m4s
Sync Release to GitHub / sync-release (release) Successful in 2s
Add Model Context Protocol (MCP) server configuration support. Users can
define MCP servers globally (new sidebar tab) and enable them per-project.
Enabled servers are injected into containers as MCP_SERVERS_JSON env var
and merged into ~/.claude.json by the entrypoint.

Backend: McpServer model, McpStore (JSON + atomic writes), 4 CRUD commands,
container injection with fingerprint-based recreation detection.
Frontend: MCP sidebar tab, McpPanel/McpServerCard components, useMcpServers
hook, per-project MCP checkboxes in ProjectCard config.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:57:12 -08:00
2ddc705925 feat: show container progress in modal and add terminal jump-to-bottom button
All checks were successful
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m19s
Build App / build-linux (push) Successful in 4m31s
Sync Release to GitHub / sync-release (release) Successful in 3s
Show container start/stop/rebuild progress as a modal popup instead of
inline text that was never visible. Add optimistic status updates so the
status dot turns yellow immediately. Also add a "Jump to Current" button
in the terminal when scrolled away from the bottom.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 08:22:35 -08:00
1aced2d860 feat: add progress feedback during slow container starts
All checks were successful
Build App / build-macos (push) Successful in 2m20s
Build App / build-windows (push) Successful in 3m17s
Build App / build-linux (push) Successful in 7m23s
Sync Release to GitHub / sync-release (release) Successful in 2s
Emit container-progress events from Rust at key milestones (checking
image, saving state, recreating, starting, stopping) and display them
in ProjectCard instead of the static "starting.../stopping..." text.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:43:01 -08:00
652e451afe fix: prevent projects from getting stuck in starting/stopping state
All checks were successful
Build App / build-macos (push) Successful in 2m21s
Build App / build-linux (push) Successful in 5m46s
Sync Release to GitHub / sync-release (release) Successful in 1s
Build App / build-windows (push) Successful in 6m35s
Reconcile stale transient statuses on app startup, add Force Stop button
for transient states, and harden stop_project_container error handling so
Docker failures don't leave projects permanently stuck.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 07:12:49 -08:00
eb86aa95b7 fix: persist full container state across stop/start and config-change recreation
All checks were successful
Build App / build-macos (push) Successful in 2m25s
Build App / build-windows (push) Successful in 2m29s
Build App / build-linux (push) Successful in 4m34s
Sync Release to GitHub / sync-release (release) Successful in 1s
- Add home volume (triple-c-home-{id}) for /home/claude to persist
  .claude.json, .local, and other user-level state across restarts
- Add docker commit before recreation: when container_needs_recreation()
  triggers, snapshot the container to preserve system-level changes
  (apt/pip/npm installs), then create the new container from that snapshot
- On Reset/removal: delete snapshot image + both volumes for clean slate
- Remove commit from stop_project_container (stop/start preserves the
  writable layer naturally; no commit needed)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 16:16:19 -08:00
3228e6cdd7 fix: Docker status never updates after Docker starts
All checks were successful
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 3m22s
Build App / build-linux (push) Successful in 5m56s
Sync Release to GitHub / sync-release (release) Successful in 1s
Replace OnceLock with Mutex<Option<Docker>> in the Rust backend so
failed Docker connections are retried instead of cached permanently.
Add frontend polling (every 5s) when Docker is initially unavailable,
stopping once detected.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:46:59 -08:00
3344ce1cbf fix: prevent spurious container recreation on every start
All checks were successful
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 4m1s
Build App / build-linux (push) Successful in 5m6s
Sync Release to GitHub / sync-release (release) Successful in 1s
The CLAUDE_INSTRUCTIONS env var was computed differently during container
creation (with port mapping docs + scheduler instructions appended) vs
the recreation check (bare merge only). This caused
container_needs_recreation() to always return true, triggering a full
recreate on every stop/start cycle.

Extract build_claude_instructions() helper used by both code paths so
the expected value always matches what was set at creation time.

Also add TODO.md noting planned tauri-plugin-updater integration for
seamless in-app updates on all platforms.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 14:22:25 -08:00
d642cc64de fix: use browser_download_url for Gitea asset downloads
The API endpoint /releases/assets/{id} returns JSON metadata, not the
binary file. Use the browser_download_url from the asset object instead.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:09:41 -08:00
e3502876eb rename Triple-C.md to README.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:07:16 -08:00
4f41f0d98b feat: add Gitea actions to sync releases to GitHub
Add two new workflows:
- sync-release.yml: automatically mirrors releases (with assets) to GitHub when published on Gitea
- backfill-releases.yml: manual workflow to bulk-sync all existing Gitea releases to GitHub

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:06:29 -08:00
c9dc232fc4 fix: remove Node.js from actual path location on Act runner
Some checks failed
Build App / build-windows (push) Successful in 3m40s
Build App / build-linux (push) Successful in 7m4s
Build App / build-macos (push) Failing after 11m34s
The Act runner has Node 18 at /opt/acttoolcache/node/18.20.3/x64/bin/,
not at /usr/local/bin/. Use $(dirname "$(which node)") to find and
remove the actual binary location before installing Node 22.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 18:08:46 -08:00
2d4fce935f fix: remove old Node.js before installing v22 on Linux runner
Some checks failed
Build App / build-linux (push) Failing after 2m34s
Build App / build-windows (push) Successful in 3m40s
Build App / build-macos (push) Has been cancelled
The Act runner has Node 18 at /usr/local/bin/node which takes
precedence over the apt-installed /usr/bin/node. Even after
running nodesource setup and apt-get install, the old Node 18
binary remained in the PATH. Now removes old binaries and uses
hash -r to force path re-lookup. Also removes package-lock.json
before npm install to ensure correct platform-specific bindings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 18:03:31 -08:00
e739f6aaff fix: check Node.js version, not just presence, in CI
Some checks failed
Build App / build-macos (push) Successful in 2m23s
Build App / build-linux (push) Failing after 3m38s
Build App / build-windows (push) Successful in 4m1s
The Act runner has Node.js v18 pre-installed, so the check
`command -v node` passes and skips installing v22. Node 18 is
too old for dependencies like vitest, jsdom, and tailwindcss/oxide.
Now checks the major version and upgrades if < 22.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:54:36 -08:00
550159fc63 Fix native binding error: use npm install instead of npm ci
Some checks failed
Build App / build-linux (push) Failing after 2m25s
Build App / build-macos (push) Successful in 2m34s
Build App / build-windows (push) Successful in 4m8s
@tailwindcss/oxide has platform-specific native bindings. The
package-lock.json was generated on a different platform, so npm ci
installs the wrong native binary. Switching to rm -rf node_modules
+ npm install lets npm resolve the correct platform-specific
optional dependency (e.g., @tailwindcss/oxide-linux-x64-gnu on
Linux, oxide-darwin-arm64 on macOS).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:44:03 -08:00
e3c874bc75 Fix cargo PATH: use explicit export in every step that needs it
Some checks failed
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 4m1s
Build App / build-linux (push) Failing after 1m30s
The Gitea Act runner's Docker container does not reliably support
$GITHUB_PATH or sourcing ~/.cargo/env across steps. Both mechanisms
failed because the runner spawns a fresh shell for each step.

Adopted the same pattern that already works for the Windows job:
explicitly set PATH at the top of every step that calls cargo or
npx tauri. This is the most portable approach across all runner
environments (Act Docker containers, bare metal macOS, Windows).

Build history shows Linux succeeded through run#46 (JS-based
actions) and failed from run#49 onward (shell-based installs).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:36:02 -08:00
6cae0e7feb Source cargo env before using rustup in install step
Some checks failed
Build App / build-macos (push) Successful in 2m22s
Build App / build-linux (push) Has been cancelled
Build App / build-windows (push) Has been cancelled
GITHUB_PATH only takes effect in subsequent steps, but rustup/rustc/cargo
are called within the same step. Adding `. "$HOME/.cargo/env"` immediately
after install puts cargo/rustup in PATH for the remainder of the step.
Fixed in both Linux and macOS jobs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:32:28 -08:00
b566446b75 Trigger multi-arch container build for ARM64 support
All checks were successful
Build Container / build-container (push) Successful in 8m40s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:31:13 -08:00
601a2db3cf Move Node.js install before checkout in Linux build
Some checks failed
Build App / build-macos (push) Failing after 6s
Build App / build-windows (push) Successful in 3m38s
Build App / build-linux (push) Failing after 3m56s
The Gitea Linux runner also lacks Node.js, causing actions/checkout@v4
(a JS action) to fail with "node: executable file not found in PATH".
Same fix as the macOS job: install Node.js via shell before any
JS-based actions run.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:28:02 -08:00
b795e27251 Fix Linux build PATH and add ARM64 container support
Some checks failed
Build App / build-linux (push) Failing after 2s
Build App / build-macos (push) Failing after 11s
Build App / build-windows (push) Successful in 3m38s
Linux app build: cargo was not in PATH for subsequent steps after
shell-based install. Fixed by adding $HOME/.cargo/bin to GITHUB_PATH
(persists across steps) and setting it in the job-level env. Also
removed the now-unnecessary per-step PATH override in the macOS job.

Container build: added QEMU setup and platforms: linux/amd64,linux/arm64
to produce a multi-arch manifest. The Dockerfile already uses
arch-aware commands (dpkg --print-architecture, uname -m) so it
builds natively on both architectures. This fixes the "no matching
manifest for linux/arm64/v8" error on Apple Silicon Macs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:16:45 -08:00
19d4cbce27 Use shell-based check-before-install for all build jobs
Some checks failed
Build App / build-macos (push) Successful in 2m34s
Build App / build-windows (push) Successful in 2m33s
Build App / build-linux (push) Failing after 3m40s
Replace JS-based GitHub Actions (dtolnay/rust-toolchain,
actions/setup-node) in the Linux job with shell commands that
check if Rust and Node.js are already present before installing.
All three jobs (Linux, macOS, Windows) now use the same pattern:
skip installation if the tool is already available on the runner.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:03:17 -08:00
946ea03956 Fix macOS CI build and add macOS build instructions
Some checks failed
Build App / build-windows (push) Has started running
Build App / build-linux (push) Has been cancelled
Build App / build-macos (push) Has been cancelled
The macOS Gitea runner lacks Node.js, causing actions/checkout@v4
(a JS action) to fail with "Cannot find: node in PATH". Fixed by
installing Node.js via Homebrew before checkout and replacing all
JS-based actions (setup-node, rust-toolchain, rust-cache) with
shell equivalents.

Also adds macOS section to BUILDING.md covering Xcode CLI tools,
universal binary targets, and Gatekeeper bypass instructions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 17:00:48 -08:00
ba4cb4176d Add macOS build job to app build workflow
Some checks failed
Build App / build-macos (push) Failing after 48s
Build App / build-windows (push) Successful in 3m9s
Build App / build-linux (push) Has been cancelled
Builds a universal binary (aarch64 + x86_64) targeting both Apple
Silicon and Intel Macs. Produces .dmg and .app.tar.gz artifacts,
uploaded to a separate Gitea release tagged with -mac suffix.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 16:57:35 -08:00
4b56610ff5 Add CLAUDE.md and HOW-TO-USE.md documentation
CLAUDE.md provides guidance for Claude Code instances working in this
repo (build commands, architecture overview, key conventions).

HOW-TO-USE.md is a user-facing guide covering prerequisites, setup,
all application features, and troubleshooting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 15:59:53 -08:00
db51abb970 Add image paste support for xterm.js terminal
All checks were successful
Build App / build-linux (push) Successful in 2m41s
Build App / build-windows (push) Successful in 3m56s
Intercept clipboard paste events containing images in the terminal,
upload them into the Docker container via bollard's tar upload API,
and inject the resulting file path into terminal stdin so Claude Code
can reference the image.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 10:52:08 -08:00
d947824436 Fix URL detector truncating wrapped URLs by flattening buffer
All checks were successful
Build App / build-linux (push) Successful in 2m32s
Build App / build-windows (push) Successful in 3m45s
Replace fragile line-by-line reassembly heuristic with a simpler
approach: flatten the buffer by converting blank lines to spaces
(URL terminators) and stripping remaining newlines (PTY wraps),
then match URLs with a single regex on the flat string.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 09:15:29 -08:00
c2b21b794c Fix URL detector truncating wrapped URLs arriving in separate PTY chunks
All checks were successful
Build App / build-linux (push) Successful in 2m42s
Build App / build-windows (push) Successful in 3m45s
The PTY may deliver a long URL across multiple chunks with enough delay
that the debounce fires between them, emitting a truncated URL. Fixed by:
1. Stripping trailing empty strings from split (artifact of trailing \n)
2. Deferring emission when the URL reaches the end of the buffer — a
   confirmation timer (500ms) waits for more data before emitting

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 08:42:52 -08:00
40493ae284 Add toast notification for wrapped long URLs in terminal
All checks were successful
Build App / build-linux (push) Successful in 2m40s
Build App / build-windows (push) Successful in 3m40s
PTY hard-wraps long URLs (e.g. OAuth) with \r\n at column width, breaking
xterm.js link detection. This adds a UrlDetector that reassembles wrapped
URLs from the output stream and shows a non-intrusive floating toast with
an "Open" button. Auto-dismisses after 30s, no terminal layout impact.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 08:29:43 -08:00
2e81b52205 Add container-native scheduled task system with timezone support
All checks were successful
Build App / build-linux (push) Successful in 2m39s
Build App / build-windows (push) Successful in 3m43s
Build Container / build-container (push) Successful in 16s
Introduces a cron-based scheduler that lets Claude set up recurring and
one-time tasks inside containers. Tasks run as separate Claude Code agents
and persist across container recreation via the named volume.

New files:
- container/triple-c-scheduler: CLI for add/remove/enable/disable/list/logs/run/notifications
- container/triple-c-task-runner: cron wrapper with flock, logging, notifications, auto-cleanup

Key changes:
- Dockerfile: add cron package and COPY both scripts
- entrypoint.sh: timezone setup, cron daemon, crontab restore, env saving
- container.rs: init=true for zombie reaping, TZ env, scheduler instructions, timezone recreation check
- image.rs: embed scheduler scripts in build context
- app_settings.rs + types.ts: timezone field
- settings_commands.rs: detect_host_timezone via iana-time-zone crate
- SettingsPanel.tsx: timezone input with auto-detection

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 15:57:22 +00:00
06be613e36 Add port mappings feature, update app icon, and enhance default instructions
All checks were successful
Build App / build-linux (push) Successful in 2m49s
Build App / build-windows (push) Successful in 4m57s
- Add per-project port mapping configuration (host:container port pairs with
  TCP/UDP protocol) stored in project config and applied as Docker port
  bindings at container creation. Port changes trigger automatic container
  recreation via fingerprint detection.
- Create PortMappingsModal UI component following the same pattern as
  EnvVarsModal, integrated into ProjectCard config panel.
- Inject port mapping details into CLAUDE_INSTRUCTIONS so Claude inside the
  container knows which ports are available for testing services.
- Update default global instructions for new installs to encourage use of
  subagents for long-running and parallel tasks.
- Replace app icons with new v2 sun logo design for better visibility at
  small sizes (taskbar/dock).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 14:36:51 +00:00
da078af73f Remove Anthropic API key authentication support
All checks were successful
Build App / build-windows (push) Successful in 2m28s
Build App / build-linux (push) Successful in 3m13s
API key auth only provides short-lived session tokens (8hrs or until
session restart) with no refresh mechanism, unlike OAuth which persists
via .credentials.json. Remove the non-functional API key settings UI
and all supporting code (frontend state, Tauri commands, keyring
storage, container env var injection, and fingerprint-based recreation
checks) to avoid user confusion.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:59:58 +00:00
01ea581f8a Fix type inference error for api_key after removing ApiKey auth mode
All checks were successful
Build App / build-linux (push) Successful in 2m42s
Build App / build-windows (push) Successful in 2m44s
Both match arms now return None, so Rust needs an explicit type
annotation for the Option<String>.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:15:50 +00:00
552aaebf16 Simplify auth modes to Anthropic and Bedrock, fix Windows taskbar icon
Some checks failed
Build App / build-linux (push) Failing after 1m40s
Build App / build-windows (push) Failing after 1m43s
Replace the three auth modes (Login, API Key, Bedrock) with two
(Anthropic, Bedrock). The Anthropic mode uses OAuth via `claude login`
inside the terminal, which generates and stores its own API key in the
persistent config volume. The separate API Key mode is removed because
Claude Code now requires interactive approval of externally-provided
keys, making the injected ANTHROPIC_API_KEY approach unreliable.

Old projects stored as "login" or "api_key" are automatically migrated
to "anthropic" via serde aliases.

Also fix the Windows taskbar icon showing as a black square by loading
icon.png instead of icon.ico for the runtime window icon.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 03:10:57 +00:00
c2736ace90 Fix API key changes not triggering container recreation
All checks were successful
Build App / build-linux (push) Successful in 2m45s
Build App / build-windows (push) Successful in 4m15s
The container was only recreated when the auth mode changed, not when
the API key value itself changed. This meant saving a new key required
a manual container rebuild. Now we store a hash of the API key as a
Docker label and compare it on start, so a key change automatically
recreates the container (preserving the claude config volume).

Also adds a note to the global AWS settings UI that changes require a
container rebuild.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 02:22:34 +00:00
2ff270ebfe Fix Windows crash from missing ICO decoder and add file logging
All checks were successful
Build App / build-linux (push) Successful in 3m7s
Build App / build-windows (push) Successful in 4m19s
The app crashed on startup because the image-ico Tauri feature was
missing, causing Image::from_bytes to panic when decoding icon.ico.
Added the feature flag and replaced env_logger with fern to log to both
stderr and <data_dir>/triple-c/logs/triple-c.log. A panic hook captures
crash details with backtraces. Store init and icon loading errors are now
logged before failing so future issues are diagnosable from the log file.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 01:45:59 +00:00
5a59fdb64b Add UX enhancements: modals for env vars and instructions, global env vars, taskbar icon fix
All checks were successful
Build App / build-linux (push) Successful in 2m38s
Build App / build-windows (push) Successful in 5m5s
- Fix Windows taskbar icon by loading icon.ico instead of icon.png (ICO contains
  multiple sizes native to Windows taskbar/title bar/alt-tab)
- Add "Container must be stopped to change settings" warning banner in config panel
- Move per-project Environment Variables and Claude Instructions into modal dialogs
  for more editing space, with buttons in the config panel to open them
- Move global Claude Instructions into a modal in Settings panel
- Add default global Claude instruction recommending git initialization
- Add global environment variables support (full stack: Rust model, TS types,
  container creation with merge logic where project overrides global for same key,
  fingerprinting for recreation checks, and Settings UI with modal)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 01:21:33 +00:00
1ce5151e59 Fix Rust build errors: use setup hook for window icon, enable image-png feature
All checks were successful
Build App / build-linux (push) Successful in 3m1s
Build App / build-windows (push) Successful in 3m49s
The previous approach used Builder::default_window_icon() which doesn't
exist in Tauri 2.10. Instead, set the icon via window.set_icon() in the
setup hook, and enable the "image-png" feature flag so Image::from_bytes
can decode the PNG icon at runtime.

Also change bundle identifier from "com.triple-c.app" to
"com.triple-c.desktop" to avoid conflicting with the .app bundle
extension on macOS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:49:08 +00:00
66ddc182c9 Exclude test files from tsc build to fix CI
Some checks failed
Build App / build-linux (push) Failing after 1m30s
Build App / build-windows (push) Failing after 2m25s
The build step runs `tsc && vite build`, and the test files that import
Node.js built-ins (fs, path) and use __dirname were causing TS2307 and
TS2304 errors during compilation. Excluding test files from tsconfig
keeps the build clean while vitest handles its own TypeScript resolution.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-01 00:42:35 +00:00
1524ec4a98 Fix sidebar content overflow and set correct window taskbar icon
Some checks failed
Build App / build-windows (push) Failing after 41s
Build App / build-linux (push) Failing after 1m25s
The sidebar config panel content was overflowing its container width,
causing project names and directory paths to be clipped. Added min-w-0
and overflow-hidden to flex containers, and restructured folder path
config rows to stack vertically instead of cramming into one line.

The Windows taskbar was showing a black square because no default window
icon was set at runtime. Added default_window_icon() call in the Tauri
builder using the app's icon.png.

Also adds vitest test infrastructure with tests verifying both fixes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:53:30 +00:00
4721950eae Set COLORTERM=truecolor in container environment
All checks were successful
Build App / build-linux (push) Successful in 2m39s
Build App / build-windows (push) Successful in 3m23s
Tells CLI tools (Claude Code, vim, etc.) that the xterm.js terminal
supports 24-bit RGB color so they use the full palette.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:30:38 +00:00
fba4b9442c Update app icons, fix sidebar path overflow, and remove terminal URL accumulator
Some checks failed
Build App / build-windows (push) Has been cancelled
Build App / build-linux (push) Has been cancelled
Replace placeholder icons with the Triple-C branded logo at all required
Tauri sizes. Remove the host_path display from sidebar folder listings to
prevent text overflow. Remove the URL accumulator that injected clickable
login URL text into the terminal — the native WebLinksAddon still handles
URLs when the window is wide enough. Add explicit logging on container
removal confirming named volumes are preserved.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 22:29:11 +00:00
75 changed files with 6274 additions and 670 deletions

View File

@@ -0,0 +1,84 @@
name: Backfill Releases to GitHub
on:
workflow_dispatch:
jobs:
backfill:
runs-on: ubuntu-latest
steps:
- name: Backfill all Gitea releases to GitHub
env:
GH_PAT: ${{ secrets.GH_PAT }}
GITEA_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
GITEA_API: https://repo.anhonesthost.net/api/v1
GITEA_REPO: cybercovellc/triple-c
GITHUB_REPO: shadowdao/triple-c
run: |
set -e
echo "==> Fetching releases from Gitea..."
RELEASES=$(curl -sf \
-H "Authorization: token $GITEA_TOKEN" \
"$GITEA_API/repos/$GITEA_REPO/releases?limit=50")
echo "$RELEASES" | jq -c '.[]' | while read release; do
TAG=$(echo "$release" | jq -r '.tag_name')
NAME=$(echo "$release" | jq -r '.name')
BODY=$(echo "$release" | jq -r '.body')
IS_PRERELEASE=$(echo "$release" | jq -r '.prerelease')
IS_DRAFT=$(echo "$release" | jq -r '.draft')
EXISTS=$(curl -sf \
-H "Authorization: Bearer $GH_PAT" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/$GITHUB_REPO/releases/tags/$TAG" \
-o /dev/null -w "%{http_code}" || true)
if [ "$EXISTS" = "200" ]; then
echo "==> Skipping $TAG (already exists on GitHub)"
continue
fi
echo "==> Creating release $TAG..."
RESPONSE=$(curl -sf -X POST \
-H "Authorization: Bearer $GH_PAT" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/json" \
https://api.github.com/repos/$GITHUB_REPO/releases \
-d "{
\"tag_name\": \"$TAG\",
\"name\": \"$NAME\",
\"body\": $(echo "$BODY" | jq -Rs .),
\"draft\": $IS_DRAFT,
\"prerelease\": $IS_PRERELEASE
}")
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
echo "$release" | jq -c '.assets[]?' | while read asset; do
ASSET_NAME=$(echo "$asset" | jq -r '.name')
ASSET_ID=$(echo "$asset" | jq -r '.id')
echo " ==> Downloading $ASSET_NAME..."
DOWNLOAD_URL=$(echo "$asset" | jq -r '.browser_download_url')
curl -sfL -o "/tmp/$ASSET_NAME" \
-H "Authorization: token $GITEA_TOKEN" \
"$DOWNLOAD_URL"
echo " ==> Uploading $ASSET_NAME to GitHub..."
ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME")
curl -sf -X POST \
-H "Authorization: Bearer $GH_PAT" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/octet-stream" \
--data-binary "@/tmp/$ASSET_NAME" \
"$UPLOAD_URL?name=$ENCODED_NAME"
echo " Uploaded: $ASSET_NAME"
done
echo "==> Done: $TAG"
done
echo "==> Backfill complete."

View File

@@ -20,6 +20,32 @@ jobs:
build-linux:
runs-on: ubuntu-latest
steps:
- name: Install Node.js 22
run: |
NEED_INSTALL=false
if command -v node >/dev/null 2>&1; then
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
OLD_NODE_DIR=$(dirname "$(which node)")
echo "Found Node.js $(node --version) at $(which node) (major: ${NODE_MAJOR})"
if [ "$NODE_MAJOR" -lt 22 ]; then
echo "Node.js ${NODE_MAJOR} is too old, removing before installing 22..."
sudo rm -f "${OLD_NODE_DIR}/node" "${OLD_NODE_DIR}/npm" "${OLD_NODE_DIR}/npx" "${OLD_NODE_DIR}/corepack"
hash -r
NEED_INSTALL=true
fi
else
echo "Node.js not found, installing 22..."
NEED_INSTALL=true
fi
if [ "$NEED_INSTALL" = true ]; then
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
hash -r
fi
echo "Node.js at: $(which node)"
node --version
npm --version
- name: Checkout
uses: actions/checkout@v4
with:
@@ -61,29 +87,35 @@ jobs:
xdg-utils
- name: Install Rust stable
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: swatinem/rust-cache@v2
with:
workspaces: "./app/src-tauri -> target"
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
run: |
if command -v rustup >/dev/null 2>&1; then
echo "Rust already installed: $(rustc --version)"
rustup update stable
rustup default stable
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
export PATH="$HOME/.cargo/bin:$PATH"
rustc --version
cargo --version
- name: Install frontend dependencies
working-directory: ./app
run: npm ci
run: |
rm -rf node_modules package-lock.json
npm install
- name: Install Tauri CLI
working-directory: ./app
run: npx tauri --version || npm install @tauri-apps/cli
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri --version || npm install @tauri-apps/cli
- name: Build Tauri app
working-directory: ./app
run: npx tauri build
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build
- name: Collect artifacts
run: |
@@ -119,6 +151,116 @@ jobs:
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
done
build-macos:
runs-on: macos-latest
steps:
- name: Install Node.js 22
run: |
NEED_INSTALL=false
if command -v node >/dev/null 2>&1; then
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
echo "Found Node.js $(node --version) (major: ${NODE_MAJOR})"
if [ "$NODE_MAJOR" -lt 22 ]; then
echo "Node.js ${NODE_MAJOR} is too old, upgrading to 22..."
NEED_INSTALL=true
fi
else
echo "Node.js not found, installing 22..."
NEED_INSTALL=true
fi
if [ "$NEED_INSTALL" = true ]; then
brew install node@22
brew link --overwrite node@22
fi
node --version
npm --version
- name: Checkout
uses: actions/checkout@v4
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 }}"
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
echo "Patched version to ${VERSION}"
- name: Install Rust stable
run: |
if command -v rustup >/dev/null 2>&1; then
echo "Rust already installed: $(rustc --version)"
rustup update stable
rustup default stable
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
export PATH="$HOME/.cargo/bin:$PATH"
rustup target add aarch64-apple-darwin x86_64-apple-darwin
rustc --version
cargo --version
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules
npm install
- name: Install Tauri CLI
working-directory: ./app
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri --version || npm install @tauri-apps/cli
- name: Build Tauri app (universal)
working-directory: ./app
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build --target universal-apple-darwin
- name: Collect artifacts
run: |
mkdir -p artifacts
cp app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg artifacts/ 2>/dev/null || true
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
ls -la artifacts/
- name: Upload to Gitea release
if: gitea.event_name == 'push'
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
TAG="v${{ steps.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 }}\"}" \
"${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}"
# Upload each artifact
for file in artifacts/*; do
[ -f "$file" ] || continue
filename=$(basename "$file")
echo "Uploading ${filename}..."
curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${file}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets?name=${filename}"
done
build-windows:
runs-on: windows-latest
defaults:

View File

@@ -21,6 +21,9 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -36,6 +39,7 @@ jobs:
with:
context: ./container
file: ./container/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ gitea.event_name == 'push' }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest

View File

@@ -0,0 +1,60 @@
name: Sync Release to GitHub
on:
release:
types: [published]
jobs:
sync-release:
runs-on: ubuntu-latest
steps:
- name: Mirror release to GitHub
env:
GH_PAT: ${{ secrets.GH_PAT }}
GITHUB_REPO: shadowdao/triple-c
RELEASE_TAG: ${{ gitea.event.release.tag_name }}
RELEASE_NAME: ${{ gitea.event.release.name }}
RELEASE_BODY: ${{ gitea.event.release.body }}
IS_PRERELEASE: ${{ gitea.event.release.prerelease }}
IS_DRAFT: ${{ gitea.event.release.draft }}
run: |
set -e
echo "==> Creating release $RELEASE_TAG on GitHub..."
RESPONSE=$(curl -sf -X POST \
-H "Authorization: Bearer $GH_PAT" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/json" \
https://api.github.com/repos/$GITHUB_REPO/releases \
-d "{
\"tag_name\": \"$RELEASE_TAG\",
\"name\": \"$RELEASE_NAME\",
\"body\": $(echo "$RELEASE_BODY" | jq -Rs .),
\"draft\": $IS_DRAFT,
\"prerelease\": $IS_PRERELEASE
}")
UPLOAD_URL=$(echo "$RESPONSE" | jq -r '.upload_url' | sed 's/{?name,label}//')
echo "Release created. Upload URL: $UPLOAD_URL"
echo '${{ toJSON(gitea.event.release.assets) }}' | jq -c '.[]' | while read asset; do
ASSET_NAME=$(echo "$asset" | jq -r '.name')
ASSET_URL=$(echo "$asset" | jq -r '.browser_download_url')
echo "==> Downloading asset: $ASSET_NAME"
curl -sfL -o "/tmp/$ASSET_NAME" "$ASSET_URL"
echo "==> Uploading $ASSET_NAME to GitHub..."
ENCODED_NAME=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$ASSET_NAME")
curl -sf -X POST \
-H "Authorization: Bearer $GH_PAT" \
-H "Accept: application/vnd.github+json" \
-H "Content-Type: application/octet-stream" \
--data-binary "@/tmp/$ASSET_NAME" \
"$UPLOAD_URL?name=$ENCODED_NAME"
echo " Uploaded: $ASSET_NAME"
done
echo "==> Release sync complete."

View File

@@ -1,6 +1,6 @@
# Building Triple-C
Triple-C is a Tauri v2 desktop application with a React/TypeScript frontend and a Rust backend. This guide covers building the app from source on Linux and Windows.
Triple-C is a Tauri v2 desktop application with a React/TypeScript frontend and a Rust backend. This guide covers building the app from source on Linux, macOS, and Windows.
## Prerequisites (All Platforms)
@@ -79,6 +79,57 @@ Build artifacts are located in `app/src-tauri/target/release/bundle/`:
| Debian pkg | `deb/*.deb` |
| RPM pkg | `rpm/*.rpm` |
## macOS
### 1. Install prerequisites
- **Xcode Command Line Tools** — required for the C/C++ toolchain and system headers:
```bash
xcode-select --install
```
No additional system libraries are needed — macOS includes WebKit natively.
### 2. Install Rust targets (universal binary)
To build a universal binary that runs on both Apple Silicon and Intel Macs:
```bash
rustup target add aarch64-apple-darwin x86_64-apple-darwin
```
### 3. Install frontend dependencies
```bash
cd app
npm ci
```
### 4. Build
For a universal binary (recommended for distribution):
```bash
npx tauri build --target universal-apple-darwin
```
For the current architecture only (faster, for local development):
```bash
npx tauri build
```
Build artifacts are located in `app/src-tauri/target/universal-apple-darwin/release/bundle/` (or `target/release/bundle/` for single-arch builds):
| Format | Path |
|--------|------|
| DMG | `dmg/*.dmg` |
| macOS App | `macos/*.app` |
| macOS App (compressed) | `macos/*.app.tar.gz` |
> **Note:** The app is not signed or notarized. On first launch, macOS Gatekeeper may block it. Right-click the app and select "Open" to bypass, or remove the quarantine attribute: `xattr -cr /Applications/Triple-C.app`
## Windows
### 1. Install prerequisites

115
CLAUDE.md Normal file
View File

@@ -0,0 +1,115 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
Triple-C (Claude-Code-Container) is a Tauri v2 desktop application that sandboxes Claude Code inside Docker containers. It has two main parts: a React/TypeScript frontend, a Rust backend, and a Docker container image definition.
## Build & Development Commands
All frontend/tauri commands run from the `app/` directory:
```bash
cd app
npm ci # Install dependencies (required first time)
npx tauri dev # Launch app in dev mode with hot reload (Vite on port 1420)
npx tauri build # Production build (outputs to src-tauri/target/release/bundle/)
npm run build # Frontend-only build (tsc + vite)
npm run test # Run Vitest once
npm run test:watch # Run Vitest in watch mode
```
Rust backend is compiled automatically by `tauri dev`/`tauri build`. To check Rust independently:
```bash
cd app/src-tauri
cargo check # Type-check without full build
cargo build # Build Rust backend only
```
Container image:
```bash
docker build -t triple-c-sandbox ./container
```
### Linux Build Dependencies (Ubuntu/Debian)
```bash
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev patchelf libssl-dev pkg-config build-essential
```
## Architecture
### Two-Process Model (Tauri IPC)
- **React frontend** (`app/src/`) renders UI in the OS webview
- **Rust backend** (`app/src-tauri/src/`) handles Docker API, credential storage, and terminal I/O
- Communication uses two patterns:
- `invoke()` — request/response for discrete operations (CRUD, start/stop containers)
- `emit()`/`listen()` — event streaming for continuous data (terminal I/O)
### Terminal I/O Flow
```
User keystroke → xterm.js onData() → invoke("terminal_input") → mpsc channel → docker exec stdin
docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → listen() → xterm.js write()
```
### Frontend Structure (`app/src/`)
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI)
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
- **`components/layout/`** — TopBar (tabs + status), Sidebar (project list), StatusBar
- **`components/projects/`** — ProjectCard, ProjectList, AddProjectDialog
- **`components/settings/`** — Settings panels for API keys, Docker, AWS
### Backend Structure (`app/src-tauri/src/`)
- **`commands/`** — Tauri command handlers (docker, project, settings, terminal). These are the IPC entry points called by `invoke()`.
- **`docker/`** — Docker API layer using bollard:
- `client.rs` — Singleton Docker connection via `OnceLock`
- `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.
- **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs`
### Container (`container/`)
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, then `sleep infinity`
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
### Container Lifecycle
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`) so OAuth tokens survive even container resets.
### Authentication
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
## Styling
- **Tailwind CSS v4** with the Vite plugin (`@tailwindcss/vite`). No separate tailwind config file.
- All colors use CSS custom properties in `index.css` `:root` (e.g., `--bg-primary`, `--text-secondary`, `--accent`)
- `color-scheme: dark` is set on `:root` for native dark-mode controls
- **Do not** add a global `* { padding: 0 }` reset — Tailwind v4 uses CSS `@layer`, and unlayered CSS overrides all layered utilities
## Key Conventions
- Frontend types in `lib/types.ts` must stay in sync with Rust structs in `models/`
- Tauri commands are registered in `lib.rs` via `.invoke_handler(tauri::generate_handler![...])`
- Tauri v2 permissions are declared in `capabilities/default.json` — new IPC commands need permission grants there
- The `projects.json` file uses atomic writes (write to `.tmp`, then `rename()`). Corrupted files are backed up to `.bak`.
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
## Testing
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
```bash
cd app
npx vitest run src/path/to/test.test.ts
```

397
HOW-TO-USE.md Normal file
View File

@@ -0,0 +1,397 @@
# 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
---
## 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**. The status dot changes from gray (stopped) to orange (starting) to green (running).
### 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.
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 auth mode 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.
---
## The Interface
```
┌─────────────────────────────────────────────────────┐
│ TopBar [ Terminal Tabs ] Docker ● Image ●│
├────────────┬────────────────────────────────────────┤
│ Sidebar │ │
│ │ Terminal View │
│ Projects │ (xterm.js) │
│ 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.
- **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 terminal session in this container |
| **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) |
### 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.
---
## 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.
### 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 (165535)
- **Container Port** — The port inside the container (165535)
- **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.
---
## AWS Bedrock Configuration
To use Claude via AWS Bedrock instead of Anthropic's API, switch the auth mode 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.
---
## 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.
### 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.
### 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.
### 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 |
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 red below the project card.
### 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 or changing mounted folders) trigger an automatic container recreation on the next start.

View File

@@ -47,8 +47,7 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
Each project can independently use one of:
- **`/login`** (OAuth): User runs `claude login` inside the terminal. Token persisted in the config volume.
- **API Key**: Stored in the OS keychain, injected as `ANTHROPIC_API_KEY` env var.
- **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).
### Container Spawning (Sibling Containers)

60
TODO.md Normal file
View File

@@ -0,0 +1,60 @@
# TODO / Future Improvements
## In-App Auto-Update via `tauri-plugin-updater`
**Priority:** High
**Status:** Planned
Currently the app detects available updates via the Gitea API (`check_for_updates` command) but cannot apply them. Users must manually download and install the new version. On macOS and Linux this is a poor experience compared to Windows (where NSIS handles upgrades cleanly).
### Recommended approach: `tauri-plugin-updater`
Full in-app auto-update: detects, downloads, verifies, and applies updates seamlessly on all platforms. The user clicks "Update" and the app restarts with the new version.
### Requirements
1. **Generate a Tauri update signing key pair** (this is Tauri's own Ed25519 key, not OS code signing):
```bash
npx @tauri-apps/cli signer generate -w ~/.tauri/triple-c.key
```
Set `TAURI_SIGNING_PRIVATE_KEY` and `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` in CI.
2. **Add `tauri-plugin-updater`** to Rust and JS dependencies.
3. **Create an update endpoint** that returns Tauri's expected JSON format:
```json
{
"version": "v0.1.100",
"notes": "Changelog here",
"pub_date": "2026-03-01T00:00:00Z",
"platforms": {
"darwin-x86_64": { "signature": "...", "url": "https://..." },
"darwin-aarch64": { "signature": "...", "url": "https://..." },
"linux-x86_64": { "signature": "...", "url": "https://..." },
"windows-x86_64": { "signature": "...", "url": "https://..." }
}
}
```
This could be a static JSON file uploaded alongside release assets, or a small API that reads from Gitea releases and reformats.
4. **Configure the updater** in `tauri.conf.json`:
```json
"plugins": {
"updater": {
"endpoints": ["https://repo.anhonesthost.net/...update-endpoint..."],
"pubkey": "<public key from step 1>"
}
}
```
5. **Add frontend UI** for the update prompt (replace or enhance the existing update check flow).
6. **Update CI pipeline** to:
- Sign bundles with the Tauri key during build
- Upload `.sig` files alongside installers
- Generate/upload the update endpoint JSON
### References
- https://v2.tauri.app/plugin/updater/
- Existing update check code: `app/src-tauri/src/commands/update_commands.rs`
- Existing models: `app/src-tauri/src/models/update_info.rs`

1185
app/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,9 @@
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"tauri": "tauri"
"tauri": "tauri",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@tauri-apps/api": "^2",
@@ -25,13 +27,17 @@
"devDependencies": {
"@tailwindcss/vite": "^4",
"@tauri-apps/cli": "^2",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4",
"autoprefixer": "^10",
"jsdom": "^28.1.0",
"postcss": "^8",
"tailwindcss": "^4",
"typescript": "^5.7",
"vite": "^6"
"vite": "^6",
"vitest": "^4.0.18"
}
}

210
app/src-tauri/Cargo.lock generated
View File

@@ -41,56 +41,6 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.102"
@@ -404,6 +354,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.11.1"
@@ -543,12 +499,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "combine"
version = "4.6.7"
@@ -938,29 +888,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "env_filter"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
dependencies = [
"log",
"regex",
]
[[package]]
name = "env_logger"
version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
dependencies = [
"anstream",
"anstyle",
"env_filter",
"jiff",
"log",
]
[[package]]
name = "equivalent"
version = "1.0.2"
@@ -1024,6 +951,16 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "fern"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4316185f709b23713e41e3195f90edef7fb00c3ed4adc79769cf09cc762a3b29"
dependencies = [
"chrono",
"log",
]
[[package]]
name = "field-offset"
version = "0.3.6"
@@ -1745,7 +1682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371"
dependencies = [
"byteorder",
"png",
"png 0.17.16",
]
[[package]]
@@ -1862,6 +1799,19 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@@ -1929,12 +1879,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.17"
@@ -1964,30 +1908,6 @@ dependencies = [
"system-deps",
]
[[package]]
name = "jiff"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3e3d65f018c6ae946ab16e80944b97096ed73c35b221d1c478a6c81d8f57940"
dependencies = [
"jiff-static",
"log",
"portable-atomic",
"portable-atomic-util",
"serde_core",
]
[[package]]
name = "jiff-static"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a17c2b211d863c7fde02cbea8a3c1a439b98e109286554f2860bdded7ff83818"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "jni"
version = "0.21.1"
@@ -2265,6 +2185,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.7.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.17.1"
@@ -2280,7 +2210,7 @@ dependencies = [
"objc2-core-foundation",
"objc2-foundation",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
@@ -2583,12 +2513,6 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "open"
version = "5.3.3"
@@ -2872,6 +2796,19 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.11.0",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
name = "polling"
version = "3.11.0"
@@ -2886,21 +2823,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "portable-atomic"
version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "portable-atomic-util"
version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -3009,6 +2931,15 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
dependencies = [
"num-traits",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -4076,6 +4007,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"image",
"jni",
"libc",
"log",
@@ -4143,7 +4075,7 @@ dependencies = [
"ico",
"json-patch",
"plist",
"png",
"png 0.17.16",
"proc-macro2",
"quote",
"semver",
@@ -4728,7 +4660,7 @@ dependencies = [
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png",
"png 0.17.16",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
@@ -4741,13 +4673,15 @@ dependencies = [
"bollard",
"chrono",
"dirs",
"env_logger",
"fern",
"futures-util",
"iana-time-zone",
"keyring",
"log",
"reqwest 0.12.28",
"serde",
"serde_json",
"sha2",
"tar",
"tauri",
"tauri-build",
@@ -4889,12 +4823,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.21.0"

View File

@@ -12,7 +12,7 @@ name = "triple-c"
path = "src/main.rs"
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["image-png", "image-ico"] }
tauri-plugin-store = "2"
tauri-plugin-dialog = "2"
tauri-plugin-opener = "2"
@@ -26,9 +26,11 @@ uuid = { version = "1", features = ["v4"] }
chrono = { version = "0.4", features = ["serde"] }
dirs = "6"
log = "0.4"
env_logger = "0.11"
fern = { version = "0.7", features = ["date-based"] }
tar = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
iana-time-zone = "0.1"
sha2 = "0.10"
[build-dependencies]
tauri-build = { version = "2", features = [] }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 372 B

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 914 B

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 B

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 108 B

After

Width:  |  Height:  |  Size: 918 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -0,0 +1,38 @@
use tauri::State;
use crate::models::McpServer;
use crate::AppState;
#[tauri::command]
pub async fn list_mcp_servers(state: State<'_, AppState>) -> Result<Vec<McpServer>, String> {
Ok(state.mcp_store.list())
}
#[tauri::command]
pub async fn add_mcp_server(
name: String,
state: State<'_, AppState>,
) -> Result<McpServer, String> {
let name = name.trim().to_string();
if name.is_empty() {
return Err("MCP server name cannot be empty.".to_string());
}
let server = McpServer::new(name);
state.mcp_store.add(server)
}
#[tauri::command]
pub async fn update_mcp_server(
server: McpServer,
state: State<'_, AppState>,
) -> Result<McpServer, String> {
state.mcp_store.update(server)
}
#[tauri::command]
pub async fn remove_mcp_server(
server_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
state.mcp_store.remove(&server_id)
}

View File

@@ -1,4 +1,5 @@
pub mod docker_commands;
pub mod mcp_commands;
pub mod project_commands;
pub mod settings_commands;
pub mod terminal_commands;

View File

@@ -1,10 +1,20 @@
use tauri::State;
use tauri::{Emitter, State};
use crate::docker;
use crate::models::{container_config, AuthMode, Project, ProjectPath, ProjectStatus};
use crate::models::{container_config, AuthMode, McpServer, Project, ProjectPath, ProjectStatus};
use crate::storage::secure;
use crate::AppState;
fn emit_progress(app_handle: &tauri::AppHandle, project_id: &str, message: &str) {
let _ = app_handle.emit(
"container-progress",
serde_json::json!({
"project_id": project_id,
"message": message,
}),
);
}
/// Extract secret fields from a project and store them in the OS keychain.
fn store_secrets_for_project(project: &Project) -> Result<(), String> {
if let Some(ref token) = project.git_token {
@@ -43,6 +53,19 @@ fn load_secrets_for_project(project: &mut Project) {
}
}
/// Resolve enabled MCP servers and filter to Docker-only ones.
fn resolve_mcp_servers(project: &Project, state: &AppState) -> (Vec<McpServer>, Vec<McpServer>) {
let all_mcp_servers = state.mcp_store.list();
let enabled_mcp: Vec<McpServer> = project.enabled_mcp_servers.iter()
.filter_map(|id| all_mcp_servers.iter().find(|s| &s.id == id).cloned())
.collect();
let docker_mcp: Vec<McpServer> = enabled_mcp.iter()
.filter(|s| s.is_docker())
.cloned()
.collect();
(enabled_mcp, docker_mcp)
}
#[tauri::command]
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
Ok(state.projects_store.list())
@@ -81,12 +104,31 @@ pub async fn remove_project(
state: State<'_, AppState>,
) -> Result<(), String> {
// Stop and remove container if it exists
if let Some(project) = state.projects_store.get(&project_id) {
if let Some(ref project) = state.projects_store.get(&project_id) {
if let Some(ref container_id) = project.container_id {
state.exec_manager.close_sessions_for_container(container_id).await;
let _ = docker::stop_container(container_id).await;
let _ = docker::remove_container(container_id).await;
}
// Remove MCP containers and network
let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(project, &state);
if !docker_mcp.is_empty() {
if let Err(e) = docker::remove_mcp_containers(&docker_mcp).await {
log::warn!("Failed to remove MCP containers for project {}: {}", project_id, e);
}
}
if let Err(e) = docker::remove_project_network(&project.id).await {
log::warn!("Failed to remove project network for project {}: {}", project_id, e);
}
// Clean up the snapshot image + volumes
if let Err(e) = docker::remove_snapshot_image(project).await {
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
}
if let Err(e) = docker::remove_project_volumes(project).await {
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
}
}
// Clean up keychain secrets for this project
@@ -109,6 +151,7 @@ pub async fn update_project(
#[tauri::command]
pub async fn start_project_container(
project_id: String,
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<Project, String> {
let mut project = state
@@ -124,26 +167,18 @@ pub async fn start_project_container(
let settings = state.settings_store.get();
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
// Get API key only if auth mode requires it
let api_key = match project.auth_mode {
AuthMode::ApiKey => {
let key = secure::get_api_key()?
.ok_or_else(|| "No API key configured. Please set your Anthropic API key in Settings.".to_string())?;
Some(key)
// 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 {
let bedrock = project.bedrock_config.as_ref()
.ok_or_else(|| "Bedrock auth mode 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());
}
AuthMode::Login => {
None
}
AuthMode::Bedrock => {
let bedrock = project.bedrock_config.as_ref()
.ok_or_else(|| "Bedrock auth mode 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());
}
None
}
};
}
// Update status to starting
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
@@ -151,6 +186,7 @@ pub async fn start_project_container(
// Wrap container operations so that any failure resets status to Stopped.
let result: Result<String, String> = async {
// Ensure image exists
emit_progress(&app_handle, &project_id, "Checking image...");
if !docker::image_exists(&image_name).await? {
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
}
@@ -164,44 +200,93 @@ pub async fn start_project_container(
// AWS config path from global settings
let aws_config_path = settings.global_aws.aws_config_path.clone();
// Check for existing container
// Set up Docker network and MCP containers if needed
let network_name = if !docker_mcp.is_empty() {
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...");
docker::start_mcp_containers(&docker_mcp, &net).await?;
Some(net)
} else {
None
};
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
let needs_recreation = docker::container_needs_recreation(
&existing_id,
&project,
settings.global_claude_instructions.as_deref(),
)
.await
.unwrap_or(false);
if needs_recreation {
log::info!("Container config changed, recreating container for project {}", project.id);
// Check if config changed — if so, snapshot + recreate
let needs_recreate = docker::container_needs_recreation(
&existing_id,
&project,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
settings.timezone.as_deref(),
&enabled_mcp,
).await.unwrap_or(false);
if needs_recreate {
log::info!("Container config changed for project {} — committing snapshot and recreating", project.id);
// Snapshot the filesystem before destroying
emit_progress(&app_handle, &project_id, "Saving container state...");
if let Err(e) = docker::commit_container_snapshot(&existing_id, &project).await {
log::warn!("Failed to snapshot container before recreation: {}", e);
}
emit_progress(&app_handle, &project_id, "Recreating container...");
let _ = docker::stop_container(&existing_id).await;
docker::remove_container(&existing_id).await?;
// Create from snapshot image (preserves system-level changes)
let snapshot_image = docker::get_snapshot_image_name(&project);
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
snapshot_image
} else {
image_name.clone()
};
let new_id = docker::create_container(
&project,
api_key.as_deref(),
&docker_socket,
&image_name,
&create_image,
aws_config_path.as_deref(),
&settings.global_aws,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
settings.timezone.as_deref(),
&enabled_mcp,
network_name.as_deref(),
).await?;
emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&new_id).await?;
new_id
} else {
emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&existing_id).await?;
existing_id
}
} else {
// Container doesn't exist (first start, or Docker pruned it).
// Check for a snapshot image first — it preserves system-level
// changes (apt/pip/npm installs) from the previous session.
let snapshot_image = docker::get_snapshot_image_name(&project);
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
log::info!("Creating container from snapshot image for project {}", project.id);
snapshot_image
} else {
image_name.clone()
};
emit_progress(&app_handle, &project_id, "Creating container...");
let new_id = docker::create_container(
&project,
api_key.as_deref(),
&docker_socket,
&image_name,
&create_image,
aws_config_path.as_deref(),
&settings.global_aws,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
settings.timezone.as_deref(),
&enabled_mcp,
network_name.as_deref(),
).await?;
emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&new_id).await?;
new_id
};
@@ -229,6 +314,7 @@ pub async fn start_project_container(
#[tauri::command]
pub async fn stop_project_container(
project_id: String,
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let project = state
@@ -236,22 +322,35 @@ pub async fn stop_project_container(
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
if let Some(ref container_id) = project.container_id {
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
if let Some(ref container_id) = project.container_id {
// Close exec sessions for this project
emit_progress(&app_handle, &project_id, "Stopping container...");
state.exec_manager.close_sessions_for_container(container_id).await;
docker::stop_container(container_id).await?;
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
if let Err(e) = docker::stop_container(container_id).await {
log::warn!("Docker stop failed for container {} (project {}): {} — resetting to Stopped anyway", container_id, project_id, e);
}
}
// Stop MCP containers (best-effort)
let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
if !docker_mcp.is_empty() {
emit_progress(&app_handle, &project_id, "Stopping MCP containers...");
if let Err(e) = docker::stop_mcp_containers(&docker_mcp).await {
log::warn!("Failed to stop MCP containers for project {}: {}", project_id, e);
}
}
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
Ok(())
}
#[tauri::command]
pub async fn rebuild_project_container(
project_id: String,
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<Project, String> {
let project = state
@@ -267,8 +366,24 @@ pub async fn rebuild_project_container(
state.projects_store.set_container_id(&project_id, None)?;
}
// Remove MCP containers before rebuild
let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
if !docker_mcp.is_empty() {
if let Err(e) = docker::remove_mcp_containers(&docker_mcp).await {
log::warn!("Failed to remove MCP containers for project {}: {}", project_id, e);
}
}
// Remove snapshot image + volumes so Reset creates from the clean base image
if let Err(e) = docker::remove_snapshot_image(&project).await {
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
}
if let Err(e) = docker::remove_project_volumes(&project).await {
log::warn!("Failed to remove project volumes for project {}: {}", project_id, e);
}
// Start fresh
start_project_container(project_id, state).await
start_project_container(project_id, app_handle, state).await
}
fn default_docker_socket() -> String {

View File

@@ -2,24 +2,8 @@ use tauri::State;
use crate::docker;
use crate::models::AppSettings;
use crate::storage::secure;
use crate::AppState;
#[tauri::command]
pub async fn set_api_key(key: String) -> Result<(), String> {
secure::store_api_key(&key)
}
#[tauri::command]
pub async fn has_api_key() -> Result<bool, String> {
secure::has_api_key()
}
#[tauri::command]
pub async fn delete_api_key() -> Result<(), String> {
secure::delete_api_key()
}
#[tauri::command]
pub async fn get_settings(state: State<'_, AppState>) -> Result<AppSettings, String> {
Ok(state.settings_store.get())
@@ -45,6 +29,33 @@ pub async fn pull_image(
.await
}
#[tauri::command]
pub async fn detect_host_timezone() -> Result<String, String> {
// Try the iana-time-zone crate first (cross-platform)
match iana_time_zone::get_timezone() {
Ok(tz) => return Ok(tz),
Err(e) => log::debug!("iana_time_zone::get_timezone() failed: {}", e),
}
// Fallback: check TZ env var
if let Ok(tz) = std::env::var("TZ") {
if !tz.is_empty() {
return Ok(tz);
}
}
// Fallback: read /etc/timezone (Linux)
if let Ok(tz) = std::fs::read_to_string("/etc/timezone") {
let tz = tz.trim().to_string();
if !tz.is_empty() {
return Ok(tz);
}
}
// Default to UTC if detection fails
Ok("UTC".to_string())
}
#[tauri::command]
pub async fn detect_aws_config() -> Result<Option<String>, String> {
if let Some(home) = dirs::home_dir() {

View File

@@ -72,3 +72,23 @@ pub async fn close_terminal_session(
state.exec_manager.close_session(&session_id).await;
Ok(())
}
#[tauri::command]
pub async fn paste_image_to_terminal(
session_id: String,
image_data: Vec<u8>,
state: State<'_, AppState>,
) -> Result<String, String> {
let container_id = state.exec_manager.get_container_id(&session_id).await?;
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
let file_name = format!("clipboard_{}.png", timestamp);
state
.exec_manager
.write_file_to_container(&container_id, &file_name, &image_data)
.await
}

View File

@@ -1,23 +1,28 @@
use bollard::Docker;
use std::sync::OnceLock;
use std::sync::Mutex;
static DOCKER: OnceLock<Result<Docker, String>> = OnceLock::new();
static DOCKER: Mutex<Option<Docker>> = Mutex::new(None);
pub fn get_docker() -> Result<&'static Docker, String> {
let result = DOCKER.get_or_init(|| {
Docker::connect_with_local_defaults()
.map_err(|e| format!("Failed to connect to Docker daemon: {}", e))
});
match result {
Ok(docker) => Ok(docker),
Err(e) => Err(e.clone()),
pub fn get_docker() -> Result<Docker, String> {
let mut guard = DOCKER.lock().map_err(|e| format!("Lock poisoned: {}", e))?;
if let Some(docker) = guard.as_ref() {
return Ok(docker.clone());
}
let docker = Docker::connect_with_local_defaults()
.map_err(|e| format!("Failed to connect to Docker daemon: {}", e))?;
guard.replace(docker.clone());
Ok(docker)
}
pub async fn check_docker_available() -> Result<bool, String> {
let docker = get_docker()?;
match docker.ping().await {
Ok(_) => Ok(true),
Err(e) => Err(format!("Docker daemon not responding: {}", e)),
Err(_) => {
// Connection object exists but daemon not responding — clear cache
let mut guard = DOCKER.lock().map_err(|e| format!("Lock poisoned: {}", e))?;
*guard = None;
Ok(false)
}
}
}

View File

@@ -2,13 +2,79 @@ use bollard::container::{
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
StartContainerOptions, StopContainerOptions,
};
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum};
use bollard::image::{CommitContainerOptions, RemoveImageOptions};
use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum, PortBinding};
use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use sha2::{Sha256, Digest};
use super::client::get_docker;
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, Project, ProjectPath};
use crate::models::{AuthMode, BedrockAuthMethod, ContainerInfo, EnvVar, GlobalAwsSettings, McpServer, McpTransportType, PortMapping, Project, ProjectPath};
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
This container supports scheduled tasks via `triple-c-scheduler`. You can set up recurring or one-time tasks that run as separate Claude Code agents.
### Commands
- `triple-c-scheduler add --name "NAME" --schedule "CRON" --prompt "TASK"` — Add a recurring task
- `triple-c-scheduler add --name "NAME" --at "YYYY-MM-DD HH:MM" --prompt "TASK"` — Add a one-time task
- `triple-c-scheduler list` — List all scheduled tasks
- `triple-c-scheduler remove --id ID` — Remove a task
- `triple-c-scheduler enable --id ID` / `triple-c-scheduler disable --id ID` — Toggle tasks
- `triple-c-scheduler logs [--id ID] [--tail N]` — View execution logs
- `triple-c-scheduler run --id ID` — Manually trigger a task immediately
- `triple-c-scheduler notifications [--clear]` — View or clear completion notifications
### Cron format
Standard 5-field cron: `minute hour day-of-month month day-of-week`
Examples: `*/30 * * * *` (every 30 min), `0 9 * * 1-5` (9am weekdays), `0 */2 * * *` (every 2 hours)
### One-time tasks
Use `--at "YYYY-MM-DD HH:MM"` instead of `--schedule`. The task automatically removes itself after execution.
### Working directory
Use `--working-dir /workspace/project` to set where the task runs (default: /workspace).
### Checking results
After tasks run, check notifications with `triple-c-scheduler notifications` and detailed output with `triple-c-scheduler logs`.
### Timezone
Scheduled times use the container's configured timezone (check with `date`). If no timezone is configured, UTC is used."#;
/// 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
/// the same value is produced in both paths.
fn build_claude_instructions(
global_instructions: Option<&str>,
project_instructions: Option<&str>,
port_mappings: &[PortMapping],
) -> Option<String> {
let mut combined = merge_claude_instructions(global_instructions, project_instructions);
if !port_mappings.is_empty() {
let mut port_lines: Vec<String> = Vec::new();
port_lines.push("## Available Port Mappings".to_string());
port_lines.push("The following ports are mapped from the host to this container. Use these container ports when starting services that need to be accessible from the host:".to_string());
for pm in port_mappings {
port_lines.push(format!(
"- Host port {} -> Container port {} ({})",
pm.host_port, pm.container_port, pm.protocol
));
}
let port_info = port_lines.join("\n");
combined = Some(match combined {
Some(existing) => format!("{}\n\n{}", existing, port_info),
None => port_info,
});
}
combined = Some(match combined {
Some(existing) => format!("{}\n\n{}", existing, SCHEDULER_INSTRUCTIONS),
None => SCHEDULER_INSTRUCTIONS.to_string(),
});
combined
}
/// Compute a fingerprint string for the custom environment variables.
/// Sorted alphabetically so order changes do not cause spurious recreation.
@@ -30,6 +96,25 @@ fn compute_env_fingerprint(custom_env_vars: &[EnvVar]) -> String {
parts.join(",")
}
/// Merge global and per-project custom environment variables.
/// Per-project variables override global variables with the same key.
fn merge_custom_env_vars(global: &[EnvVar], project: &[EnvVar]) -> Vec<EnvVar> {
let mut merged: std::collections::HashMap<String, EnvVar> = std::collections::HashMap::new();
for ev in global {
let key = ev.key.trim().to_string();
if !key.is_empty() {
merged.insert(key, ev.clone());
}
}
for ev in project {
let key = ev.key.trim().to_string();
if !key.is_empty() {
merged.insert(key, ev.clone());
}
}
merged.into_values().collect()
}
/// Merge global and per-project Claude instructions into a single string.
fn merge_claude_instructions(
global_instructions: Option<&str>,
@@ -43,20 +128,28 @@ fn merge_claude_instructions(
}
}
/// Hash a string with SHA-256 and return the hex digest.
fn sha256_hex(input: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(input.as_bytes());
format!("{:x}", hasher.finalize())
}
/// Compute a fingerprint for the Bedrock configuration so we can detect changes.
fn compute_bedrock_fingerprint(project: &Project) -> String {
if let Some(ref bedrock) = project.bedrock_config {
let mut hasher = DefaultHasher::new();
format!("{:?}", bedrock.auth_method).hash(&mut hasher);
bedrock.aws_region.hash(&mut hasher);
bedrock.aws_access_key_id.hash(&mut hasher);
bedrock.aws_secret_access_key.hash(&mut hasher);
bedrock.aws_session_token.hash(&mut hasher);
bedrock.aws_profile.hash(&mut hasher);
bedrock.aws_bearer_token.hash(&mut hasher);
bedrock.model_id.hash(&mut hasher);
bedrock.disable_prompt_caching.hash(&mut hasher);
format!("{:x}", hasher.finish())
let parts = vec![
format!("{:?}", bedrock.auth_method),
bedrock.aws_region.clone(),
bedrock.aws_access_key_id.as_deref().unwrap_or("").to_string(),
bedrock.aws_secret_access_key.as_deref().unwrap_or("").to_string(),
bedrock.aws_session_token.as_deref().unwrap_or("").to_string(),
bedrock.aws_profile.as_deref().unwrap_or("").to_string(),
bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(),
bedrock.model_id.as_deref().unwrap_or("").to_string(),
format!("{}", bedrock.disable_prompt_caching),
];
sha256_hex(&parts.join("|"))
} else {
String::new()
}
@@ -71,9 +164,96 @@ fn compute_paths_fingerprint(paths: &[ProjectPath]) -> String {
.collect();
parts.sort();
let joined = parts.join(",");
let mut hasher = DefaultHasher::new();
joined.hash(&mut hasher);
format!("{:x}", hasher.finish())
sha256_hex(&joined)
}
/// Compute a fingerprint for port mappings so we can detect changes.
/// Sorted so order changes don't cause spurious recreation.
fn compute_ports_fingerprint(port_mappings: &[PortMapping]) -> String {
let mut parts: Vec<String> = port_mappings
.iter()
.map(|p| format!("{}:{}:{}", p.host_port, p.container_port, p.protocol))
.collect();
parts.sort();
let joined = parts.join(",");
sha256_hex(&joined)
}
/// Build the JSON value for MCP servers config to be injected into ~/.claude.json.
/// Produces `{"mcpServers": {"name": {"type": "stdio", ...}, ...}}`.
///
/// Handles 4 modes:
/// - Stdio+Docker: `docker exec -i <mcp-container-name> <command> ...args`
/// - Stdio+Manual: `<command> ...args` (existing behavior)
/// - HTTP+Docker: `streamableHttp` URL pointing to `http://<mcp-container-name>:<port>/mcp`
/// - HTTP+Manual: `streamableHttp` with user-provided URL + headers
fn build_mcp_servers_json(servers: &[McpServer]) -> String {
let mut mcp_map = serde_json::Map::new();
for server in servers {
let mut entry = serde_json::Map::new();
match server.transport_type {
McpTransportType::Stdio => {
entry.insert("type".to_string(), serde_json::json!("stdio"));
if server.is_docker() {
// Stdio+Docker: use `docker exec` to communicate with MCP container
entry.insert("command".to_string(), serde_json::json!("docker"));
let mut args = vec![
"exec".to_string(),
"-i".to_string(),
server.mcp_container_name(),
];
if let Some(ref cmd) = server.command {
args.push(cmd.clone());
}
args.extend(server.args.iter().cloned());
entry.insert("args".to_string(), serde_json::json!(args));
} else {
// Stdio+Manual: existing behavior
if let Some(ref cmd) = server.command {
entry.insert("command".to_string(), serde_json::json!(cmd));
}
if !server.args.is_empty() {
entry.insert("args".to_string(), serde_json::json!(server.args));
}
}
if !server.env.is_empty() {
entry.insert("env".to_string(), serde_json::json!(server.env));
}
}
McpTransportType::Http => {
entry.insert("type".to_string(), serde_json::json!("streamableHttp"));
if server.is_docker() {
// HTTP+Docker: point to MCP container by name on the shared network
let url = format!(
"http://{}:{}/mcp",
server.mcp_container_name(),
server.effective_container_port()
);
entry.insert("url".to_string(), serde_json::json!(url));
} else {
// HTTP+Manual: user-provided URL + headers
if let Some(ref url) = server.url {
entry.insert("url".to_string(), serde_json::json!(url));
}
if !server.headers.is_empty() {
entry.insert("headers".to_string(), serde_json::json!(server.headers));
}
}
}
}
mcp_map.insert(server.name.clone(), serde_json::Value::Object(entry));
}
let wrapper = serde_json::json!({ "mcpServers": mcp_map });
serde_json::to_string(&wrapper).unwrap_or_default()
}
/// Compute a fingerprint for MCP server configuration so we can detect changes.
fn compute_mcp_fingerprint(servers: &[McpServer]) -> String {
if servers.is_empty() {
return String::new();
}
let json = build_mcp_servers_json(servers);
sha256_hex(&json)
}
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
@@ -108,18 +288,24 @@ pub async fn find_existing_container(project: &Project) -> Result<Option<String>
pub async fn create_container(
project: &Project,
api_key: Option<&str>,
docker_socket_path: &str,
image_name: &str,
aws_config_path: Option<&str>,
global_aws: &GlobalAwsSettings,
global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar],
timezone: Option<&str>,
mcp_servers: &[McpServer],
network_name: Option<&str>,
) -> Result<String, String> {
let docker = get_docker()?;
let container_name = project.container_name();
let mut env_vars: Vec<String> = Vec::new();
// Tell CLI tools the terminal supports 24-bit RGB color
env_vars.push("COLORTERM=truecolor".to_string());
// Pass host UID/GID so the entrypoint can remap the container user
#[cfg(unix)]
{
@@ -153,10 +339,6 @@ pub async fn create_container(
log::debug!("Skipping HOST_UID/HOST_GID on Windows — Docker Desktop's Linux VM handles user mapping");
}
if let Some(key) = api_key {
env_vars.push(format!("ANTHROPIC_API_KEY={}", key));
}
if let Some(ref token) = project.git_token {
env_vars.push(format!("GIT_TOKEN={}", token));
}
@@ -219,9 +401,10 @@ pub async fn create_container(
}
}
// Custom environment variables
// 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_"];
for env_var in &project.custom_env_vars {
for env_var in &merged_env {
let key = env_var.key.trim();
if key.is_empty() {
continue;
@@ -233,18 +416,33 @@ pub async fn create_container(
}
env_vars.push(format!("{}={}", key, env_var.value));
}
let custom_env_fingerprint = compute_env_fingerprint(&project.custom_env_vars);
let custom_env_fingerprint = compute_env_fingerprint(&merged_env);
env_vars.push(format!("TRIPLE_C_CUSTOM_ENV={}", custom_env_fingerprint));
// Claude instructions (global + per-project)
let combined_instructions = merge_claude_instructions(
// Container timezone
if let Some(tz) = timezone {
if !tz.is_empty() {
env_vars.push(format!("TZ={}", tz));
}
}
// 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,
);
if let Some(ref instructions) = combined_instructions {
env_vars.push(format!("CLAUDE_INSTRUCTIONS={}", instructions));
}
// MCP servers config
if !mcp_servers.is_empty() {
let mcp_json = build_mcp_servers_json(mcp_servers);
env_vars.push(format!("MCP_SERVERS_JSON={}", mcp_json));
}
let mut mounts: Vec<Mount> = Vec::new();
// Project directories -> /workspace/{mount_name}
@@ -258,7 +456,19 @@ pub async fn create_container(
});
}
// Named volume for claude config persistence
// Named volume for the entire home directory — preserves ~/.claude.json,
// ~/.local (pip/npm globals), and any other user-level state across
// container stop/start cycles.
mounts.push(Mount {
target: Some("/home/claude".to_string()),
source: Some(format!("triple-c-home-{}", project.id)),
typ: Some(MountTypeEnum::VOLUME),
read_only: Some(false),
..Default::default()
});
// Named volume for claude config persistence — mounted as a nested volume
// inside the home volume; Docker gives the more-specific mount precedence.
mounts.push(Mount {
target: Some("/home/claude/.claude".to_string()),
source: Some(format!("triple-c-claude-config-{}", project.id)),
@@ -308,8 +518,12 @@ pub async fn create_container(
}
}
// Docker socket (only if allowed)
if project.allow_docker_access {
// Docker socket (if allowed, or auto-enabled for stdio+Docker MCP servers)
let needs_docker_for_mcp = any_stdio_docker_mcp(mcp_servers);
if project.allow_docker_access || needs_docker_for_mcp {
if needs_docker_for_mcp && !project.allow_docker_access {
log::info!("Auto-enabling Docker socket access for stdio+Docker MCP servers");
}
// On Windows, the named pipe (//./pipe/docker_engine) cannot be
// bind-mounted into a Linux container. Docker Desktop exposes the
// daemon socket as /var/run/docker.sock for container mounts.
@@ -327,6 +541,21 @@ pub async fn create_container(
});
}
// Port mappings
let mut exposed_ports: HashMap<String, HashMap<(), ()>> = HashMap::new();
let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
for pm in &project.port_mappings {
let container_key = format!("{}/{}", pm.container_port, pm.protocol);
exposed_ports.insert(container_key.clone(), HashMap::new());
port_bindings.insert(
container_key,
Some(vec![PortBinding {
host_ip: Some("0.0.0.0".to_string()),
host_port: Some(pm.host_port.to_string()),
}]),
);
}
let mut labels = HashMap::new();
labels.insert("triple-c.managed".to_string(), "true".to_string());
labels.insert("triple-c.project-id".to_string(), project.id.clone());
@@ -334,10 +563,17 @@ pub async fn create_container(
labels.insert("triple-c.auth-mode".to_string(), format!("{:?}", project.auth_mode));
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.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));
let host_config = HostConfig {
mounts: Some(mounts),
port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
init: Some(true),
// Connect to project network if specified (for MCP container communication)
network_mode: network_name.map(|n| n.to_string()),
..Default::default()
};
@@ -354,6 +590,7 @@ pub async fn create_container(
labels: Some(labels),
working_dir: Some(working_dir),
host_config: Some(host_config),
exposed_ports: if exposed_ports.is_empty() { None } else { Some(exposed_ports) },
tty: Some(true),
..Default::default()
};
@@ -392,6 +629,10 @@ pub async fn stop_container(container_id: &str) -> Result<(), String> {
pub async fn remove_container(container_id: &str) -> Result<(), String> {
let docker = get_docker()?;
log::info!(
"Removing container {} (v=false: named volumes such as claude config are preserved)",
container_id
);
docker
.remove_container(
container_id,
@@ -405,6 +646,83 @@ pub async fn remove_container(container_id: &str) -> Result<(), String> {
.map_err(|e| format!("Failed to remove container: {}", e))
}
/// Return the snapshot image name for a project.
pub fn get_snapshot_image_name(project: &Project) -> String {
format!("triple-c-snapshot-{}:latest", project.id)
}
/// Commit the container's filesystem to a snapshot image so that system-level
/// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container
/// removal. The Config is left empty so that secrets injected as env vars are
/// NOT baked into the image.
pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> Result<(), String> {
let docker = get_docker()?;
let image_name = get_snapshot_image_name(project);
// Parse repo:tag
let (repo, tag) = match image_name.rsplit_once(':') {
Some((r, t)) => (r.to_string(), t.to_string()),
None => (image_name.clone(), "latest".to_string()),
};
let options = CommitContainerOptions {
container: container_id.to_string(),
repo: repo.clone(),
tag: tag.clone(),
pause: true,
..Default::default()
};
// Empty config — no env vars / cmd baked in
let config = Config::<String> {
..Default::default()
};
docker
.commit_container(options, config)
.await
.map_err(|e| format!("Failed to commit container snapshot: {}", e))?;
log::info!("Committed container {} as snapshot {}:{}", container_id, repo, tag);
Ok(())
}
/// Remove the snapshot image for a project (used on Reset / project removal).
pub async fn remove_snapshot_image(project: &Project) -> Result<(), String> {
let docker = get_docker()?;
let image_name = get_snapshot_image_name(project);
docker
.remove_image(
&image_name,
Some(RemoveImageOptions {
force: true,
noprune: false,
}),
None,
)
.await
.map_err(|e| format!("Failed to remove snapshot image {}: {}", image_name, e))?;
log::info!("Removed snapshot image {}", image_name);
Ok(())
}
/// Remove both named volumes for a project (used on Reset / project removal).
pub async fn remove_project_volumes(project: &Project) -> Result<(), String> {
let docker = get_docker()?;
for vol in [
format!("triple-c-home-{}", project.id),
format!("triple-c-claude-config-{}", project.id),
] {
match docker.remove_volume(&vol, None).await {
Ok(_) => log::info!("Removed volume {}", vol),
Err(e) => log::warn!("Failed to remove volume {} (may not exist): {}", vol, e),
}
}
Ok(())
}
/// Check whether the existing container's configuration still matches the
/// current project settings. Returns `true` when the container must be
/// recreated (mounts or env vars differ).
@@ -412,6 +730,9 @@ pub async fn container_needs_recreation(
container_id: &str,
project: &Project,
global_claude_instructions: Option<&str>,
global_custom_env_vars: &[EnvVar],
timezone: Option<&str>,
mcp_servers: &[McpServer],
) -> Result<bool, String> {
let docker = get_docker()?;
let info = docker
@@ -464,6 +785,14 @@ pub async fn container_needs_recreation(
}
}
// ── Port mappings fingerprint ──────────────────────────────────────────
let expected_ports_fp = compute_ports_fingerprint(&project.port_mappings);
let container_ports_fp = get_label("triple-c.ports-fingerprint").unwrap_or_default();
if container_ports_fp != expected_ports_fp {
log::info!("Port mappings fingerprint mismatch (container={:?}, expected={:?})", container_ports_fp, expected_ports_fp);
return Ok(true);
}
// ── Bedrock config fingerprint ───────────────────────────────────────
let expected_bedrock_fp = compute_bedrock_fingerprint(project);
let container_bedrock_fp = get_label("triple-c.bedrock-fingerprint").unwrap_or_default();
@@ -491,6 +820,14 @@ pub async fn container_needs_recreation(
}
}
// ── Timezone ─────────────────────────────────────────────────────────
let expected_tz = timezone.unwrap_or("");
let container_tz = get_label("triple-c.timezone").unwrap_or_default();
if container_tz != expected_tz {
log::info!("Timezone mismatch (container={:?}, expected={:?})", container_tz, expected_tz);
return Ok(true);
}
// ── SSH key path mount ───────────────────────────────────────────────
let ssh_mount_source = mounts
.and_then(|m| {
@@ -540,7 +877,8 @@ pub async fn container_needs_recreation(
}
// ── Custom environment variables ──────────────────────────────────────
let expected_fingerprint = compute_env_fingerprint(&project.custom_env_vars);
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
let expected_fingerprint = compute_env_fingerprint(&merged_env);
let container_fingerprint = get_env("TRIPLE_C_CUSTOM_ENV").unwrap_or_default();
if container_fingerprint != expected_fingerprint {
log::info!("Custom env vars mismatch (container={:?}, expected={:?})", container_fingerprint, expected_fingerprint);
@@ -548,9 +886,10 @@ pub async fn container_needs_recreation(
}
// ── Claude instructions ───────────────────────────────────────────────
let expected_instructions = merge_claude_instructions(
let expected_instructions = build_claude_instructions(
global_claude_instructions,
project.claude_instructions.as_deref(),
&project.port_mappings,
);
let container_instructions = get_env("CLAUDE_INSTRUCTIONS");
if container_instructions.as_deref() != expected_instructions.as_deref() {
@@ -558,6 +897,14 @@ pub async fn container_needs_recreation(
return Ok(true);
}
// ── MCP servers fingerprint ─────────────────────────────────────────
let expected_mcp_fp = compute_mcp_fingerprint(mcp_servers);
let container_mcp_fp = get_label("triple-c.mcp-fingerprint").unwrap_or_default();
if container_mcp_fp != expected_mcp_fp {
log::info!("MCP servers fingerprint mismatch (container={:?}, expected={:?})", container_mcp_fp, expected_mcp_fp);
return Ok(true);
}
Ok(false)
}
@@ -616,3 +963,178 @@ pub async fn list_sibling_containers() -> Result<Vec<ContainerSummary>, String>
Ok(siblings)
}
// ── MCP Container Lifecycle ─────────────────────────────────────────────
/// Returns true if any MCP server uses stdio transport with Docker.
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()?;
let container_name = server.mcp_container_name();
let filters: HashMap<String, Vec<String>> = HashMap::from([
("name".to_string(), vec![container_name.clone()]),
]);
let containers: Vec<ContainerSummary> = docker
.list_containers(Some(ListContainersOptions {
all: true,
filters,
..Default::default()
}))
.await
.map_err(|e| format!("Failed to list MCP containers: {}", e))?;
let expected = format!("/{}", container_name);
for c in &containers {
if let Some(names) = &c.names {
if names.iter().any(|n| n == &expected) {
return Ok(c.id.clone());
}
}
}
Ok(None)
}
/// Create a Docker container for an MCP server.
pub async fn create_mcp_container(
server: &McpServer,
network_name: &str,
) -> Result<String, String> {
let docker = get_docker()?;
let container_name = server.mcp_container_name();
let image = server
.docker_image
.as_ref()
.ok_or_else(|| format!("MCP server '{}' has no docker_image", server.name))?;
let mut env_vars: Vec<String> = Vec::new();
for (k, v) in &server.env {
env_vars.push(format!("{}={}", k, v));
}
// Build command + args as Cmd
let mut cmd: Vec<String> = Vec::new();
if let Some(ref command) = server.command {
cmd.push(command.clone());
}
cmd.extend(server.args.iter().cloned());
let mut labels = HashMap::new();
labels.insert("triple-c.managed".to_string(), "true".to_string());
labels.insert("triple-c.mcp-server".to_string(), server.id.clone());
let host_config = HostConfig {
network_mode: Some(network_name.to_string()),
..Default::default()
};
let config = Config {
image: Some(image.clone()),
env: if env_vars.is_empty() { None } else { Some(env_vars) },
cmd: if cmd.is_empty() { None } else { Some(cmd) },
labels: Some(labels),
host_config: Some(host_config),
..Default::default()
};
let options = CreateContainerOptions {
name: container_name.clone(),
..Default::default()
};
let response = docker
.create_container(Some(options), config)
.await
.map_err(|e| format!("Failed to create MCP container '{}': {}", container_name, e))?;
log::info!(
"Created MCP container {} (image: {}) on network {}",
container_name,
image,
network_name
);
Ok(response.id)
}
/// Start all Docker-based MCP server containers. Finds or creates each one.
pub async fn start_mcp_containers(
servers: &[McpServer],
network_name: &str,
) -> Result<(), String> {
for server in servers {
if !server.is_docker() {
continue;
}
let container_id = if let Some(existing_id) = find_mcp_container(server).await? {
log::debug!("Found existing MCP container for '{}'", server.name);
existing_id
} else {
create_mcp_container(server, network_name).await?
};
// Start the container (ignore already-started errors)
if let Err(e) = start_container(&container_id).await {
let err_str = e.to_string();
if err_str.contains("already started") || err_str.contains("304") {
log::debug!("MCP container '{}' already running", server.name);
} else {
return Err(format!(
"Failed to start MCP container '{}': {}",
server.name, e
));
}
}
log::info!("MCP container '{}' started", server.name);
}
Ok(())
}
/// Stop all Docker-based MCP server containers (best-effort).
pub async fn stop_mcp_containers(servers: &[McpServer]) -> Result<(), String> {
for server in servers {
if !server.is_docker() {
continue;
}
if let Ok(Some(container_id)) = find_mcp_container(server).await {
if let Err(e) = stop_container(&container_id).await {
log::warn!("Failed to stop MCP container '{}': {}", server.name, e);
} else {
log::info!("Stopped MCP container '{}'", server.name);
}
}
}
Ok(())
}
/// Stop and remove all Docker-based MCP server containers (best-effort).
pub async fn remove_mcp_containers(servers: &[McpServer]) -> Result<(), String> {
for server in servers {
if !server.is_docker() {
continue;
}
if let Ok(Some(container_id)) = find_mcp_container(server).await {
let _ = stop_container(&container_id).await;
if let Err(e) = remove_container(&container_id).await {
log::warn!("Failed to remove MCP container '{}': {}", server.name, e);
} else {
log::info!("Removed MCP container '{}'", server.name);
}
}
}
Ok(())
}

View File

@@ -1,3 +1,4 @@
use bollard::container::UploadToContainerOptions;
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
use futures_util::StreamExt;
use std::collections::HashMap;
@@ -212,4 +213,51 @@ impl ExecSessionManager {
session.shutdown();
}
}
pub async fn get_container_id(&self, session_id: &str) -> Result<String, String> {
let sessions = self.sessions.lock().await;
let session = sessions
.get(session_id)
.ok_or_else(|| format!("Session {} not found", session_id))?;
Ok(session.container_id.clone())
}
pub async fn write_file_to_container(
&self,
container_id: &str,
file_name: &str,
data: &[u8],
) -> Result<String, String> {
let docker = get_docker()?;
// Build a tar archive in memory containing the file
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(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, file_name, 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: "/tmp".to_string(),
..Default::default()
}),
tar_buf.into(),
)
.await
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
Ok(format!("/tmp/{}", file_name))
}
}

View File

@@ -9,6 +9,8 @@ use crate::models::container_config;
const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile");
const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh");
const SCHEDULER: &str = include_str!("../../../../container/triple-c-scheduler");
const TASK_RUNNER: &str = include_str!("../../../../container/triple-c-task-runner");
pub async fn image_exists(image_name: &str) -> Result<bool, String> {
let docker = get_docker()?;
@@ -135,6 +137,20 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
header.set_cksum();
archive.append_data(&mut header, "entrypoint.sh", entrypoint_bytes)?;
let scheduler_bytes = SCHEDULER.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(scheduler_bytes.len() as u64);
header.set_mode(0o755);
header.set_cksum();
archive.append_data(&mut header, "triple-c-scheduler", scheduler_bytes)?;
let task_runner_bytes = TASK_RUNNER.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(task_runner_bytes.len() as u64);
header.set_mode(0o755);
header.set_cksum();
archive.append_data(&mut header, "triple-c-task-runner", task_runner_bytes)?;
archive.finish()?;
}

View File

@@ -2,8 +2,10 @@ pub mod client;
pub mod container;
pub mod image;
pub mod exec;
pub mod network;
pub use client::*;
pub use container::*;
pub use image::*;
pub use exec::*;
pub use network::*;

View File

@@ -0,0 +1,128 @@
use bollard::network::{CreateNetworkOptions, InspectNetworkOptions};
use std::collections::HashMap;
use super::client::get_docker;
/// Network name for a project's MCP containers.
fn project_network_name(project_id: &str) -> String {
format!("triple-c-net-{}", project_id)
}
/// Ensure a Docker bridge network exists for the project.
/// Returns the network name.
pub async fn ensure_project_network(project_id: &str) -> Result<String, String> {
let docker = get_docker()?;
let network_name = project_network_name(project_id);
// Check if network already exists
match docker
.inspect_network(&network_name, None::<InspectNetworkOptions<String>>)
.await
{
Ok(_) => {
log::debug!("Network {} already exists", network_name);
return Ok(network_name);
}
Err(_) => {
// Network doesn't exist, create it
}
}
let options = CreateNetworkOptions {
name: network_name.clone(),
driver: "bridge".to_string(),
labels: HashMap::from([
("triple-c.managed".to_string(), "true".to_string()),
("triple-c.project-id".to_string(), project_id.to_string()),
]),
..Default::default()
};
docker
.create_network(options)
.await
.map_err(|e| format!("Failed to create network {}: {}", network_name, e))?;
log::info!("Created Docker network {}", network_name);
Ok(network_name)
}
/// Connect a container to the project network.
pub async fn connect_container_to_network(
container_id: &str,
network_name: &str,
) -> Result<(), String> {
let docker = get_docker()?;
let config = bollard::network::ConnectNetworkOptions {
container: container_id.to_string(),
..Default::default()
};
docker
.connect_network(network_name, config)
.await
.map_err(|e| {
format!(
"Failed to connect container {} to network {}: {}",
container_id, network_name, e
)
})?;
log::debug!(
"Connected container {} to network {}",
container_id,
network_name
);
Ok(())
}
/// Remove the project network (best-effort). Disconnects all containers first.
pub async fn remove_project_network(project_id: &str) -> Result<(), String> {
let docker = get_docker()?;
let network_name = project_network_name(project_id);
// Inspect to get connected containers
let info = match docker
.inspect_network(&network_name, None::<InspectNetworkOptions<String>>)
.await
{
Ok(info) => info,
Err(_) => {
log::debug!(
"Network {} not found, nothing to remove",
network_name
);
return Ok(());
}
};
// Disconnect all containers
if let Some(containers) = info.containers {
for (container_id, _) in containers {
let disconnect_opts = bollard::network::DisconnectNetworkOptions {
container: container_id.clone(),
force: true,
};
if let Err(e) = docker
.disconnect_network(&network_name, disconnect_opts)
.await
{
log::warn!(
"Failed to disconnect container {} from network {}: {}",
container_id,
network_name,
e
);
}
}
}
// Remove the network
match docker.remove_network(&network_name).await {
Ok(_) => log::info!("Removed Docker network {}", network_name),
Err(e) => log::warn!("Failed to remove network {}: {}", network_name, e),
}
Ok(())
}

View File

@@ -1,31 +1,70 @@
mod commands;
mod docker;
mod logging;
mod models;
mod storage;
use docker::exec::ExecSessionManager;
use storage::projects_store::ProjectsStore;
use storage::settings_store::SettingsStore;
use storage::mcp_store::McpStore;
use tauri::Manager;
pub struct AppState {
pub projects_store: ProjectsStore,
pub settings_store: SettingsStore,
pub mcp_store: McpStore,
pub exec_manager: ExecSessionManager,
}
pub fn run() {
env_logger::init();
logging::init();
let projects_store = match ProjectsStore::new() {
Ok(s) => s,
Err(e) => {
log::error!("Failed to initialize projects store: {}", e);
panic!("Failed to initialize projects store: {}", e);
}
};
let settings_store = match SettingsStore::new() {
Ok(s) => s,
Err(e) => {
log::error!("Failed to initialize settings store: {}", e);
panic!("Failed to initialize settings store: {}", e);
}
};
let mcp_store = match McpStore::new() {
Ok(s) => s,
Err(e) => {
log::error!("Failed to initialize MCP store: {}", e);
panic!("Failed to initialize MCP store: {}", e);
}
};
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.manage(AppState {
projects_store: ProjectsStore::new().expect("Failed to initialize projects store"),
settings_store: SettingsStore::new().expect("Failed to initialize settings store"),
projects_store,
settings_store,
mcp_store,
exec_manager: ExecSessionManager::new(),
})
.setup(|app| {
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
Ok(icon) => {
if let Some(window) = app.get_webview_window("main") {
let _ = window.set_icon(icon);
}
}
Err(e) => {
log::error!("Failed to load window icon: {}", e);
}
}
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
let state = window.state::<AppState>();
@@ -50,19 +89,23 @@ pub fn run() {
commands::project_commands::stop_project_container,
commands::project_commands::rebuild_project_container,
// Settings
commands::settings_commands::set_api_key,
commands::settings_commands::has_api_key,
commands::settings_commands::delete_api_key,
commands::settings_commands::get_settings,
commands::settings_commands::update_settings,
commands::settings_commands::pull_image,
commands::settings_commands::detect_aws_config,
commands::settings_commands::list_aws_profiles,
commands::settings_commands::detect_host_timezone,
// Terminal
commands::terminal_commands::open_terminal_session,
commands::terminal_commands::terminal_input,
commands::terminal_commands::terminal_resize,
commands::terminal_commands::close_terminal_session,
commands::terminal_commands::paste_image_to_terminal,
// 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,
// Updates
commands::update_commands::get_app_version,
commands::update_commands::check_for_updates,

View File

@@ -0,0 +1,73 @@
use std::fs;
use std::path::PathBuf;
/// Returns the log directory path: `<data_dir>/triple-c/logs/`
fn log_dir() -> Option<PathBuf> {
dirs::data_dir().map(|d| d.join("triple-c").join("logs"))
}
/// Initialise logging to both stderr and a log file in the app data directory.
///
/// Logs are written to `<data_dir>/triple-c/logs/triple-c.log`.
/// A panic hook is also installed so that unexpected crashes are captured in the
/// same log file before the process exits.
pub fn init() {
let log_file_path = log_dir().and_then(|dir| {
fs::create_dir_all(&dir).ok()?;
let path = dir.join("triple-c.log");
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.ok()
.map(|file| (path, file))
});
let mut dispatch = fern::Dispatch::new()
.format(|out, message, record| {
out.finish(format_args!(
"[{} {} {}] {}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
record.level(),
record.target(),
message
))
})
.level(log::LevelFilter::Info)
.chain(std::io::stderr());
if let Some((_path, file)) = &log_file_path {
dispatch = dispatch.chain(fern::Dispatch::new().chain(file.try_clone().unwrap()));
}
if let Err(e) = dispatch.apply() {
eprintln!("Failed to initialise logger: {}", e);
}
// Install a panic hook that writes to the log file so crashes are captured.
let crash_log_dir = log_dir();
std::panic::set_hook(Box::new(move |info| {
let msg = format!(
"[{} PANIC] {}\nBacktrace:\n{:?}",
chrono::Local::now().format("%Y-%m-%d %H:%M:%S"),
info,
std::backtrace::Backtrace::force_capture(),
);
eprintln!("{}", msg);
if let Some(ref dir) = crash_log_dir {
let crash_path = dir.join("triple-c.log");
let _ = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&crash_path)
.and_then(|mut f| {
use std::io::Write;
writeln!(f, "{}", msg)
});
}
}));
if let Some((ref path, _)) = log_file_path {
log::info!("Logging to {}", path.display());
}
}

View File

@@ -1,9 +1,15 @@
use serde::{Deserialize, Serialize};
use super::project::EnvVar;
fn default_true() -> bool {
true
}
fn default_global_instructions() -> Option<String> {
Some("If the project is not initialized with git, recommend to the user to initialize and use git to track changes. This makes it easier to revert should something break.\n\nUse subagents frequently. For long-running tasks, break the work into parallel subagents where possible. When handling multiple separate tasks, delegate each to its own subagent so they can run concurrently.".to_string())
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ImageSource {
@@ -54,12 +60,16 @@ pub struct AppSettings {
pub custom_image_name: Option<String>,
#[serde(default)]
pub global_aws: GlobalAwsSettings,
#[serde(default)]
#[serde(default = "default_global_instructions")]
pub global_claude_instructions: Option<String>,
#[serde(default)]
pub global_custom_env_vars: Vec<EnvVar>,
#[serde(default = "default_true")]
pub auto_check_updates: bool,
#[serde(default)]
pub dismissed_update_version: Option<String>,
#[serde(default)]
pub timezone: Option<String>,
}
impl Default for AppSettings {
@@ -72,9 +82,11 @@ impl Default for AppSettings {
image_source: ImageSource::default(),
custom_image_name: None,
global_aws: GlobalAwsSettings::default(),
global_claude_instructions: None,
global_claude_instructions: default_global_instructions(),
global_custom_env_vars: Vec::new(),
auto_check_updates: true,
dismissed_update_version: None,
timezone: None,
}
}
}

View File

@@ -0,0 +1,70 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum McpTransportType {
Stdio,
#[serde(alias = "sse")]
Http,
}
impl Default for McpTransportType {
fn default() -> Self {
Self::Stdio
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServer {
pub id: String,
pub name: String,
#[serde(default)]
pub transport_type: McpTransportType,
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
pub url: Option<String>,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub docker_image: Option<String>,
#[serde(default)]
pub container_port: Option<u16>,
pub created_at: String,
pub updated_at: String,
}
impl McpServer {
pub fn new(name: String) -> Self {
let now = chrono::Utc::now().to_rfc3339();
Self {
id: uuid::Uuid::new_v4().to_string(),
name,
transport_type: McpTransportType::default(),
command: None,
args: Vec::new(),
env: HashMap::new(),
url: None,
headers: HashMap::new(),
docker_image: None,
container_port: None,
created_at: now.clone(),
updated_at: now,
}
}
pub fn is_docker(&self) -> bool {
self.docker_image.is_some()
}
pub fn mcp_container_name(&self) -> String {
format!("triple-c-mcp-{}", self.id)
}
pub fn effective_container_port(&self) -> u16 {
self.container_port.unwrap_or(3000)
}
}

View File

@@ -2,8 +2,10 @@ pub mod project;
pub mod container_config;
pub mod app_settings;
pub mod update_info;
pub mod mcp_server;
pub use project::*;
pub use container_config::*;
pub use app_settings::*;
pub use update_info::*;
pub use mcp_server::*;

View File

@@ -12,6 +12,18 @@ pub struct ProjectPath {
pub mount_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PortMapping {
pub host_port: u16,
pub container_port: u16,
#[serde(default = "default_protocol")]
pub protocol: String,
}
fn default_protocol() -> String {
"tcp".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: String,
@@ -30,7 +42,11 @@ pub struct Project {
#[serde(default)]
pub custom_env_vars: Vec<EnvVar>,
#[serde(default)]
pub port_mappings: Vec<PortMapping>,
#[serde(default)]
pub claude_instructions: Option<String>,
#[serde(default)]
pub enabled_mcp_servers: Vec<String>,
pub created_at: String,
pub updated_at: String,
}
@@ -46,20 +62,21 @@ pub enum ProjectStatus {
}
/// How the project authenticates with Claude.
/// - `Login`: User runs `claude login` inside the container (OAuth, persisted via config volume)
/// - `ApiKey`: Uses the API key stored in the OS keychain
/// - `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
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AuthMode {
Login,
ApiKey,
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
#[serde(alias = "login", alias = "api_key")]
Anthropic,
Bedrock,
}
impl Default for AuthMode {
fn default() -> Self {
Self::Login
Self::Anthropic
}
}
@@ -113,7 +130,9 @@ impl Project {
git_user_name: None,
git_user_email: None,
custom_env_vars: Vec::new(),
port_mappings: Vec::new(),
claude_instructions: None,
enabled_mcp_servers: Vec::new(),
created_at: now.clone(),
updated_at: now,
}

View File

@@ -0,0 +1,106 @@
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use crate::models::McpServer;
pub struct McpStore {
servers: Mutex<Vec<McpServer>>,
file_path: PathBuf,
}
impl McpStore {
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or_else(|| "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string())?
.join("triple-c");
fs::create_dir_all(&data_dir).ok();
let file_path = data_dir.join("mcp_servers.json");
let servers = if file_path.exists() {
match fs::read_to_string(&file_path) {
Ok(data) => {
match serde_json::from_str::<Vec<McpServer>>(&data) {
Ok(parsed) => parsed,
Err(e) => {
log::error!("Failed to parse mcp_servers.json: {}. Starting with empty list.", e);
let backup = file_path.with_extension("json.bak");
if let Err(be) = fs::copy(&file_path, &backup) {
log::error!("Failed to back up corrupted mcp_servers.json: {}", be);
}
Vec::new()
}
}
}
Err(e) => {
log::error!("Failed to read mcp_servers.json: {}", e);
Vec::new()
}
}
} else {
Vec::new()
};
Ok(Self {
servers: Mutex::new(servers),
file_path,
})
}
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<McpServer>> {
self.servers.lock().unwrap_or_else(|e| e.into_inner())
}
fn save(&self, servers: &[McpServer]) -> Result<(), String> {
let data = serde_json::to_string_pretty(servers)
.map_err(|e| format!("Failed to serialize MCP servers: {}", e))?;
// Atomic write: write to temp file, then rename
let tmp_path = self.file_path.with_extension("json.tmp");
fs::write(&tmp_path, data)
.map_err(|e| format!("Failed to write temp MCP servers file: {}", e))?;
fs::rename(&tmp_path, &self.file_path)
.map_err(|e| format!("Failed to rename MCP servers file: {}", e))?;
Ok(())
}
pub fn list(&self) -> Vec<McpServer> {
self.lock().clone()
}
pub fn get(&self, id: &str) -> Option<McpServer> {
self.lock().iter().find(|s| s.id == id).cloned()
}
pub fn add(&self, server: McpServer) -> Result<McpServer, String> {
let mut servers = self.lock();
let cloned = server.clone();
servers.push(server);
self.save(&servers)?;
Ok(cloned)
}
pub fn update(&self, updated: McpServer) -> Result<McpServer, String> {
let mut servers = self.lock();
if let Some(s) = servers.iter_mut().find(|s| s.id == updated.id) {
*s = updated.clone();
self.save(&servers)?;
Ok(updated)
} else {
Err(format!("MCP server {} not found", updated.id))
}
}
pub fn remove(&self, id: &str) -> Result<(), String> {
let mut servers = self.lock();
let initial_len = servers.len();
servers.retain(|s| s.id != id);
if servers.len() == initial_len {
return Err(format!("MCP server {} not found", id));
}
self.save(&servers)?;
Ok(())
}
}

View File

@@ -1,7 +1,9 @@
pub mod projects_store;
pub mod secure;
pub mod settings_store;
pub mod mcp_store;
pub use projects_store::*;
pub use secure::*;
pub use settings_store::*;
pub use mcp_store::*;

View File

@@ -70,17 +70,38 @@ impl ProjectsStore {
(Vec::new(), false)
};
// Reconcile stale transient statuses: on a cold app start no Docker
// operations can be in flight, so Starting/Stopping are always stale.
let mut projects = projects;
let mut needs_save = needs_save;
for p in projects.iter_mut() {
match p.status {
crate::models::ProjectStatus::Starting | crate::models::ProjectStatus::Stopping => {
log::warn!(
"Reconciling stale '{}' status for project '{}' ({}) → Stopped",
serde_json::to_string(&p.status).unwrap_or_default().trim_matches('"'),
p.name,
p.id
);
p.status = crate::models::ProjectStatus::Stopped;
p.updated_at = chrono::Utc::now().to_rfc3339();
needs_save = true;
}
_ => {}
}
}
let store = Self {
projects: Mutex::new(projects),
file_path,
};
// Persist migrated format back to disk
// Persist migrated/reconciled format back to disk
if needs_save {
log::info!("Migrated projects.json from single-path to multi-path format");
log::info!("Saving reconciled/migrated projects.json to disk");
let projects = store.lock();
if let Err(e) = store.save(&projects) {
log::error!("Failed to save migrated projects: {}", e);
log::error!("Failed to save projects: {}", e);
}
}

View File

@@ -1,42 +1,3 @@
const SERVICE_NAME: &str = "triple-c";
const API_KEY_USER: &str = "anthropic-api-key";
pub fn store_api_key(key: &str) -> Result<(), String> {
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
.map_err(|e| format!("Keyring error: {}", e))?;
entry
.set_password(key)
.map_err(|e| format!("Failed to store API key: {}", e))
}
pub fn get_api_key() -> Result<Option<String>, String> {
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
.map_err(|e| format!("Keyring error: {}", e))?;
match entry.get_password() {
Ok(key) => Ok(Some(key)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("Failed to retrieve API key: {}", e)),
}
}
pub fn delete_api_key() -> Result<(), String> {
let entry = keyring::Entry::new(SERVICE_NAME, API_KEY_USER)
.map_err(|e| format!("Keyring error: {}", e))?;
match entry.delete_credential() {
Ok(()) => Ok(()),
Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(format!("Failed to delete API key: {}", e)),
}
}
pub fn has_api_key() -> Result<bool, String> {
match get_api_key() {
Ok(Some(_)) => Ok(true),
Ok(None) => Ok(false),
Err(e) => Err(e),
}
}
/// Store a per-project secret in the OS keychain.
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
let service = format!("triple-c-project-{}-{}", project_id, key_name);

View File

@@ -2,7 +2,7 @@
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
"productName": "Triple-C",
"version": "0.1.0",
"identifier": "com.triple-c.app",
"identifier": "com.triple-c.desktop",
"build": {
"beforeDevCommand": "npm run dev",
"devUrl": "http://localhost:1420",

View File

@@ -7,13 +7,15 @@ import TerminalView from "./components/terminal/TerminalView";
import { useDocker } from "./hooks/useDocker";
import { useSettings } from "./hooks/useSettings";
import { useProjects } from "./hooks/useProjects";
import { useMcpServers } from "./hooks/useMcpServers";
import { useUpdates } from "./hooks/useUpdates";
import { useAppState } from "./store/appState";
export default function App() {
const { checkDocker, checkImage } = useDocker();
const { checkApiKey, loadSettings } = useSettings();
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 }))
@@ -22,11 +24,16 @@ export default function App() {
// Initialize on mount
useEffect(() => {
loadSettings();
let stopPolling: (() => void) | undefined;
checkDocker().then((available) => {
if (available) checkImage();
if (available) {
checkImage();
} else {
stopPolling = startDockerPolling();
}
});
checkApiKey();
refresh();
refreshMcp();
// Update detection
loadVersion();
@@ -35,6 +42,7 @@ export default function App() {
return () => {
clearTimeout(updateTimer);
cleanup?.();
stopPolling?.();
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps

View File

@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import Sidebar from "./Sidebar";
// Mock zustand store
vi.mock("../../store/appState", () => ({
useAppState: vi.fn((selector) =>
selector({
sidebarView: "projects",
setSidebarView: vi.fn(),
})
),
}));
// Mock child components to isolate Sidebar layout testing
vi.mock("../projects/ProjectList", () => ({
default: () => <div data-testid="project-list">ProjectList</div>,
}));
vi.mock("../settings/SettingsPanel", () => ({
default: () => <div data-testid="settings-panel">SettingsPanel</div>,
}));
vi.mock("../mcp/McpPanel", () => ({
default: () => <div data-testid="mcp-panel">McpPanel</div>,
}));
describe("Sidebar", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders the sidebar with content area", () => {
render(<Sidebar />);
expect(screen.getByText("Projects")).toBeInTheDocument();
expect(screen.getByText("Settings")).toBeInTheDocument();
});
it("content area has min-w-0 to prevent flex overflow", () => {
const { container } = render(<Sidebar />);
const contentArea = container.querySelector(".overflow-y-auto");
expect(contentArea).not.toBeNull();
expect(contentArea!.className).toContain("min-w-0");
});
it("content area has overflow-x-hidden to prevent horizontal scroll", () => {
const { container } = render(<Sidebar />);
const contentArea = container.querySelector(".overflow-y-auto");
expect(contentArea).not.toBeNull();
expect(contentArea!.className).toContain("overflow-x-hidden");
});
it("sidebar outer container has overflow-hidden", () => {
const { container } = render(<Sidebar />);
const sidebar = container.firstElementChild;
expect(sidebar).not.toBeNull();
expect(sidebar!.className).toContain("overflow-hidden");
});
});

View File

@@ -1,6 +1,7 @@
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../../store/appState";
import ProjectList from "../projects/ProjectList";
import McpPanel from "../mcp/McpPanel";
import SettingsPanel from "../settings/SettingsPanel";
export default function Sidebar() {
@@ -8,35 +9,37 @@ export default function Sidebar() {
useShallow(s => ({ sidebarView: s.sidebarView, setSidebarView: s.setSidebarView }))
);
const tabCls = (view: typeof sidebarView) =>
`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
sidebarView === view
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`;
return (
<div className="flex flex-col h-full w-[25%] min-w-56 max-w-80 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
{/* Nav tabs */}
<div className="flex border-b border-[var(--border-color)]">
<button
onClick={() => setSidebarView("projects")}
className={`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
sidebarView === "projects"
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
<button onClick={() => setSidebarView("projects")} className={tabCls("projects")}>
Projects
</button>
<button
onClick={() => setSidebarView("settings")}
className={`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
sidebarView === "settings"
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
<button onClick={() => setSidebarView("mcp")} className={tabCls("mcp")}>
MCP <span className="text-[0.6rem] px-1 py-0.5 rounded bg-yellow-500/20 text-yellow-400 ml-0.5">Beta</span>
</button>
<button onClick={() => setSidebarView("settings")} className={tabCls("settings")}>
Settings
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-1">
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1 min-w-0">
{sidebarView === "projects" ? (
<ProjectList />
) : sidebarView === "mcp" ? (
<McpPanel />
) : (
<SettingsPanel />
)}
</div>
</div>
);

View File

@@ -0,0 +1,79 @@
import { useState, useEffect } from "react";
import { useMcpServers } from "../../hooks/useMcpServers";
import McpServerCard from "./McpServerCard";
export default function McpPanel() {
const { mcpServers, refresh, add, update, remove } = useMcpServers();
const [newName, setNewName] = useState("");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
refresh();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleAdd = async () => {
const name = newName.trim();
if (!name) return;
setError(null);
try {
await add(name);
setNewName("");
} catch (e) {
setError(String(e));
}
};
return (
<div className="space-y-3 p-2">
<div>
<h2 className="text-sm font-semibold text-[var(--text-primary)]">
MCP Servers{" "}
<span className="text-xs px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-400">Beta</span>
</h2>
<p className="text-xs text-[var(--text-secondary)] mt-0.5">
Define MCP servers globally, then enable them per-project.
</p>
</div>
{/* Add new server */}
<div className="flex gap-1">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }}
placeholder="Server name..."
className="flex-1 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)]"
/>
<button
onClick={handleAdd}
disabled={!newName.trim()}
className="px-3 py-1 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
Add
</button>
</div>
{error && (
<div className="text-xs text-[var(--error)]">{error}</div>
)}
{/* Server list */}
<div className="space-y-2">
{mcpServers.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)] italic">
No MCP servers configured.
</p>
) : (
mcpServers.map((server) => (
<McpServerCard
key={server.id}
server={server}
onUpdate={update}
onRemove={remove}
/>
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,323 @@
import { useState, useEffect } from "react";
import type { McpServer, McpTransportType } from "../../lib/types";
interface Props {
server: McpServer;
onUpdate: (server: McpServer) => Promise<McpServer | void>;
onRemove: (id: string) => Promise<void>;
}
export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
const [expanded, setExpanded] = useState(false);
const [name, setName] = useState(server.name);
const [transportType, setTransportType] = useState<McpTransportType>(server.transport_type);
const [command, setCommand] = useState(server.command ?? "");
const [args, setArgs] = useState(server.args.join(" "));
const [envPairs, setEnvPairs] = useState<[string, string][]>(Object.entries(server.env));
const [url, setUrl] = useState(server.url ?? "");
const [headerPairs, setHeaderPairs] = useState<[string, string][]>(Object.entries(server.headers));
const [dockerImage, setDockerImage] = useState(server.docker_image ?? "");
const [containerPort, setContainerPort] = useState(server.container_port?.toString() ?? "3000");
useEffect(() => {
setName(server.name);
setTransportType(server.transport_type);
setCommand(server.command ?? "");
setArgs(server.args.join(" "));
setEnvPairs(Object.entries(server.env));
setUrl(server.url ?? "");
setHeaderPairs(Object.entries(server.headers));
setDockerImage(server.docker_image ?? "");
setContainerPort(server.container_port?.toString() ?? "3000");
}, [server]);
const saveServer = async (patch: Partial<McpServer>) => {
try {
await onUpdate({ ...server, ...patch });
} catch (err) {
console.error("Failed to update MCP server:", err);
}
};
const handleNameBlur = () => {
if (name !== server.name) saveServer({ name });
};
const handleTransportChange = (t: McpTransportType) => {
setTransportType(t);
saveServer({ transport_type: t });
};
const handleCommandBlur = () => {
saveServer({ command: command || null });
};
const handleArgsBlur = () => {
const parsed = args.trim() ? args.trim().split(/\s+/) : [];
saveServer({ args: parsed });
};
const handleUrlBlur = () => {
saveServer({ url: url || null });
};
const handleDockerImageBlur = () => {
saveServer({ docker_image: dockerImage || null });
};
const handleContainerPortBlur = () => {
const port = parseInt(containerPort, 10);
saveServer({ container_port: isNaN(port) ? null : port });
};
const saveEnv = (pairs: [string, string][]) => {
const env: Record<string, string> = {};
for (const [k, v] of pairs) {
if (k.trim()) env[k.trim()] = v;
}
saveServer({ env });
};
const saveHeaders = (pairs: [string, string][]) => {
const headers: Record<string, string> = {};
for (const [k, v] of pairs) {
if (k.trim()) headers[k.trim()] = v;
}
saveServer({ headers });
};
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)]";
const isDocker = !!dockerImage;
const transportBadge = {
stdio: "Stdio",
http: "HTTP",
}[transportType];
const modeBadge = isDocker ? "Docker" : "Manual";
return (
<div className="border border-[var(--border-color)] rounded bg-[var(--bg-primary)]">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2">
<button
onClick={() => setExpanded(!expanded)}
className="flex-1 flex items-center gap-2 text-left min-w-0"
>
<span className="text-xs text-[var(--text-secondary)]">{expanded ? "\u25BC" : "\u25B6"}</span>
<span className="text-sm font-medium truncate">{server.name}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-secondary)] text-[var(--text-secondary)]">
{transportBadge}
</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${isDocker ? "bg-blue-500/20 text-blue-400" : "bg-[var(--bg-secondary)] text-[var(--text-secondary)]"}`}>
{modeBadge}
</span>
</button>
<button
onClick={() => { if (confirm(`Remove MCP server "${server.name}"?`)) onRemove(server.id); }}
className="text-xs px-2 py-0.5 text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
>
Remove
</button>
</div>
{/* Expanded config */}
{expanded && (
<div className="px-3 pb-3 space-y-2 border-t border-[var(--border-color)] pt-2">
{/* Name */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Name</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={handleNameBlur}
className={inputCls}
/>
</div>
{/* Docker Image (primary field — determines Docker vs Manual mode) */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Docker Image</label>
<input
value={dockerImage}
onChange={(e) => setDockerImage(e.target.value)}
onBlur={handleDockerImageBlur}
placeholder="e.g. mcp/filesystem:latest (leave empty for manual mode)"
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.
</p>
</div>
{/* Transport type */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Transport</label>
<div className="flex items-center gap-1">
{(["stdio", "http"] as McpTransportType[]).map((t) => (
<button
key={t}
onClick={() => handleTransportChange(t)}
className={`px-2 py-0.5 text-xs rounded transition-colors ${
transportType === t
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]"
}`}
>
{t === "stdio" ? "Stdio" : "HTTP"}
</button>
))}
</div>
</div>
{/* Container Port (HTTP+Docker only) */}
{transportType === "http" && isDocker && (
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Container Port</label>
<input
value={containerPort}
onChange={(e) => setContainerPort(e.target.value)}
onBlur={handleContainerPortBlur}
placeholder="3000"
className={inputCls}
/>
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
Port inside the MCP container (default: 3000)
</p>
</div>
)}
{/* Stdio fields */}
{transportType === "stdio" && (
<>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Command</label>
<input
value={command}
onChange={(e) => setCommand(e.target.value)}
onBlur={handleCommandBlur}
placeholder={isDocker ? "Command inside container" : "npx"}
className={inputCls}
/>
</div>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Arguments (space-separated)</label>
<input
value={args}
onChange={(e) => setArgs(e.target.value)}
onBlur={handleArgsBlur}
placeholder="-y @modelcontextprotocol/server-filesystem /path"
className={inputCls}
/>
</div>
<KeyValueEditor
label="Environment Variables"
pairs={envPairs}
onChange={(pairs) => { setEnvPairs(pairs); }}
onSave={saveEnv}
/>
</>
)}
{/* HTTP fields (only for manual mode — Docker mode auto-generates URL) */}
{transportType === "http" && !isDocker && (
<>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">URL</label>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
onBlur={handleUrlBlur}
placeholder="http://localhost:3000/mcp"
className={inputCls}
/>
</div>
<KeyValueEditor
label="Headers"
pairs={headerPairs}
onChange={(pairs) => { setHeaderPairs(pairs); }}
onSave={saveHeaders}
/>
</>
)}
{/* Environment variables for HTTP+Docker */}
{transportType === "http" && isDocker && (
<KeyValueEditor
label="Environment Variables"
pairs={envPairs}
onChange={(pairs) => { setEnvPairs(pairs); }}
onSave={saveEnv}
/>
)}
</div>
)}
</div>
);
}
function KeyValueEditor({
label,
pairs,
onChange,
onSave,
}: {
label: string;
pairs: [string, string][];
onChange: (pairs: [string, string][]) => void;
onSave: (pairs: [string, string][]) => void;
}) {
const inputCls = "flex-1 min-w-0 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)]";
return (
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">{label}</label>
{pairs.map(([key, value], i) => (
<div key={i} className="flex gap-1 items-center mb-1">
<input
value={key}
onChange={(e) => {
const updated = [...pairs] as [string, string][];
updated[i] = [e.target.value, value];
onChange(updated);
}}
onBlur={() => onSave(pairs)}
placeholder="KEY"
className={inputCls}
/>
<span className="text-xs text-[var(--text-secondary)]">=</span>
<input
value={value}
onChange={(e) => {
const updated = [...pairs] as [string, string][];
updated[i] = [key, e.target.value];
onChange(updated);
}}
onBlur={() => onSave(pairs)}
placeholder="value"
className={inputCls}
/>
<button
onClick={() => {
const updated = pairs.filter((_, j) => j !== i);
onChange(updated);
onSave(updated);
}}
className="flex-shrink-0 px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
>
x
</button>
</div>
))}
<button
onClick={() => {
onChange([...pairs, ["", ""]]);
}}
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
>
+ Add
</button>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { useState, useEffect, useRef, useCallback } from "react";
interface Props {
instructions: string;
disabled: boolean;
onSave: (instructions: string) => Promise<void>;
onClose: () => void;
}
export default function ClaudeInstructionsModal({ instructions: initial, disabled, onSave, onClose }: Props) {
const [instructions, setInstructions] = useState(initial);
const overlayRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
textareaRef.current?.focus();
}, []);
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 handleBlur = async () => {
try { await onSave(instructions); } catch (err) {
console.error("Failed to update Claude instructions:", err);
}
};
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-[40rem] shadow-xl max-h-[80vh] flex flex-col">
<h2 className="text-lg font-semibold mb-1">Claude Instructions</h2>
<p className="text-xs text-[var(--text-secondary)] mb-4">
Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)
</p>
{disabled && (
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
Container must be stopped to change Claude instructions.
</div>
)}
<textarea
ref={textareaRef}
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
onBlur={handleBlur}
placeholder="Enter instructions for Claude Code in this project's container..."
disabled={disabled}
rows={14}
className="w-full flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 resize-y font-mono"
/>
<div className="flex justify-end mt-4">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,109 @@
import { useEffect, useRef, useCallback } from "react";
interface Props {
projectName: string;
operation: "starting" | "stopping" | "resetting";
progressMsg: string | null;
error: string | null;
completed: boolean;
onForceStop: () => void;
onClose: () => void;
}
const operationLabels: Record<string, string> = {
starting: "Starting",
stopping: "Stopping",
resetting: "Resetting",
};
export default function ContainerProgressModal({
projectName,
operation,
progressMsg,
error,
completed,
onForceStop,
onClose,
}: Props) {
const overlayRef = useRef<HTMLDivElement>(null);
// Auto-close on success after 800ms
useEffect(() => {
if (completed && !error) {
const timer = setTimeout(onClose, 800);
return () => clearTimeout(timer);
}
}, [completed, error, onClose]);
// Escape to close (only when completed or error)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && (completed || error)) onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [completed, error, onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current && (completed || error)) onClose();
},
[completed, error, onClose],
);
const inProgress = !completed && !error;
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-80 shadow-xl text-center">
<h3 className="text-sm font-semibold mb-4">
{operationLabels[operation]} &ldquo;{projectName}&rdquo;
</h3>
{/* Spinner / checkmark / error icon */}
<div className="flex justify-center mb-3">
{error ? (
<span className="text-3xl text-[var(--error)]"></span>
) : completed ? (
<span className="text-3xl text-[var(--success)]"></span>
) : (
<div className="w-8 h-8 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
)}
</div>
{/* Progress message */}
<p className="text-xs text-[var(--text-secondary)] min-h-[1.25rem] mb-4">
{error
? <span className="text-[var(--error)]">{error}</span>
: completed
? "Done!"
: progressMsg ?? `${operationLabels[operation]}...`}
</p>
{/* Buttons */}
<div className="flex justify-center gap-2">
{inProgress && (
<button
onClick={(e) => { e.stopPropagation(); onForceStop(); }}
className="px-3 py-1.5 text-xs text-[var(--error)] border border-[var(--error)]/30 rounded hover:bg-[var(--error)]/10 transition-colors"
>
Force Stop
</button>
)}
{(completed || error) && (
<button
onClick={(e) => { e.stopPropagation(); onClose(); }}
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded transition-colors"
>
Close
</button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { EnvVar } from "../../lib/types";
interface Props {
envVars: EnvVar[];
disabled: boolean;
onSave: (vars: EnvVar[]) => Promise<void>;
onClose: () => void;
}
export default function EnvVarsModal({ envVars: initial, disabled, onSave, onClose }: Props) {
const [vars, setVars] = useState<EnvVar[]>(initial);
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 updateVar = (index: number, field: keyof EnvVar, value: string) => {
const updated = [...vars];
updated[index] = { ...updated[index], [field]: value };
setVars(updated);
};
const removeVar = async (index: number) => {
const updated = vars.filter((_, i) => i !== index);
setVars(updated);
try { await onSave(updated); } catch (err) {
console.error("Failed to remove environment variable:", err);
}
};
const addVar = async () => {
const updated = [...vars, { key: "", value: "" }];
setVars(updated);
try { await onSave(updated); } catch (err) {
console.error("Failed to add environment variable:", err);
}
};
const handleBlur = async () => {
try { await onSave(vars); } catch (err) {
console.error("Failed to update environment variables:", err);
}
};
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-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
<h2 className="text-lg font-semibold mb-4">Environment Variables</h2>
{disabled && (
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
Container must be stopped to change environment variables.
</div>
)}
<div className="space-y-2 mb-4">
{vars.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No environment variables configured.</p>
)}
{vars.map((ev, i) => (
<div key={i} className="flex gap-2 items-center">
<input
value={ev.key}
onChange={(e) => updateVar(i, "key", e.target.value)}
onBlur={handleBlur}
placeholder="KEY"
disabled={disabled}
className="w-2/5 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<input
value={ev.value}
onChange={(e) => updateVar(i, "value", e.target.value)}
onBlur={handleBlur}
placeholder="value"
disabled={disabled}
className="flex-1 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<button
onClick={() => removeVar(i)}
disabled={disabled}
className="px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
>
x
</button>
</div>
))}
</div>
<div className="flex justify-between items-center">
<button
onClick={addVar}
disabled={disabled}
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
+ Add variable
</button>
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { PortMapping } from "../../lib/types";
interface Props {
portMappings: PortMapping[];
disabled: boolean;
onSave: (mappings: PortMapping[]) => Promise<void>;
onClose: () => void;
}
export default function PortMappingsModal({ portMappings: initial, disabled, onSave, onClose }: Props) {
const [mappings, setMappings] = useState<PortMapping[]>(initial);
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 updatePort = (index: number, field: "host_port" | "container_port", value: string) => {
const updated = [...mappings];
const num = parseInt(value, 10);
updated[index] = { ...updated[index], [field]: isNaN(num) ? 0 : num };
setMappings(updated);
};
const updateProtocol = (index: number, value: string) => {
const updated = [...mappings];
updated[index] = { ...updated[index], protocol: value };
setMappings(updated);
};
const removeMapping = async (index: number) => {
const updated = mappings.filter((_, i) => i !== index);
setMappings(updated);
try { await onSave(updated); } catch (err) {
console.error("Failed to remove port mapping:", err);
}
};
const addMapping = async () => {
const updated = [...mappings, { host_port: 0, container_port: 0, protocol: "tcp" }];
setMappings(updated);
try { await onSave(updated); } catch (err) {
console.error("Failed to add port mapping:", err);
}
};
const handleBlur = async () => {
try { await onSave(mappings); } catch (err) {
console.error("Failed to update port mappings:", err);
}
};
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-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
<h2 className="text-lg font-semibold mb-2">Port Mappings</h2>
<p className="text-xs text-[var(--text-secondary)] mb-4">
Map host ports to container ports. Services can be started after the container is running.
</p>
{disabled && (
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
Container must be stopped to change port mappings.
</div>
)}
<div className="space-y-2 mb-4">
{mappings.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No port mappings configured.</p>
)}
{mappings.length > 0 && (
<div className="flex gap-2 items-center text-xs text-[var(--text-secondary)] px-0.5">
<span className="w-[30%]">Host Port</span>
<span className="w-[30%]">Container Port</span>
<span className="w-[25%]">Protocol</span>
<span className="w-[15%]" />
</div>
)}
{mappings.map((pm, i) => (
<div key={i} className="flex gap-2 items-center">
<input
type="number"
min="1"
max="65535"
value={pm.host_port || ""}
onChange={(e) => updatePort(i, "host_port", e.target.value)}
onBlur={handleBlur}
placeholder="8080"
disabled={disabled}
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<input
type="number"
min="1"
max="65535"
value={pm.container_port || ""}
onChange={(e) => updatePort(i, "container_port", e.target.value)}
onBlur={handleBlur}
placeholder="8080"
disabled={disabled}
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<select
value={pm.protocol}
onChange={(e) => { updateProtocol(i, e.target.value); handleBlur(); }}
disabled={disabled}
className="w-[25%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
<button
onClick={() => removeMapping(i)}
disabled={disabled}
className="w-[15%] px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors text-center"
>
x
</button>
</div>
))}
</div>
<div className="flex justify-between items-center">
<button
onClick={addMapping}
disabled={disabled}
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
+ Add port mapping
</button>
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import ProjectCard from "./ProjectCard";
import type { Project } from "../../lib/types";
// Mock Tauri dialog plugin
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(),
}));
// Mock hooks
const mockUpdate = vi.fn();
const mockStart = vi.fn();
const mockStop = vi.fn();
const mockRebuild = vi.fn();
const mockRemove = vi.fn();
vi.mock("../../hooks/useProjects", () => ({
useProjects: () => ({
start: mockStart,
stop: mockStop,
rebuild: mockRebuild,
remove: mockRemove,
update: mockUpdate,
}),
}));
vi.mock("../../hooks/useTerminal", () => ({
useTerminal: () => ({
open: vi.fn(),
}),
}));
vi.mock("../../hooks/useMcpServers", () => ({
useMcpServers: () => ({
mcpServers: [],
refresh: vi.fn(),
add: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
}),
}));
let mockSelectedProjectId: string | null = null;
vi.mock("../../store/appState", () => ({
useAppState: vi.fn((selector) =>
selector({
selectedProjectId: mockSelectedProjectId,
setSelectedProject: vi.fn(),
})
),
}));
const mockProject: Project = {
id: "test-1",
name: "Test Project",
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
container_id: null,
status: "stopped",
auth_mode: "anthropic",
bedrock_config: null,
allow_docker_access: false,
ssh_key_path: null,
git_token: null,
git_user_name: null,
git_user_email: null,
custom_env_vars: [],
port_mappings: [],
claude_instructions: null,
enabled_mcp_servers: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
describe("ProjectCard", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSelectedProjectId = null;
});
it("renders project name and path", () => {
render(<ProjectCard project={mockProject} />);
expect(screen.getByText("Test Project")).toBeInTheDocument();
expect(screen.getByText("/workspace/project")).toBeInTheDocument();
});
it("card root has min-w-0 and overflow-hidden to contain content", () => {
const { container } = render(<ProjectCard project={mockProject} />);
const card = container.firstElementChild;
expect(card).not.toBeNull();
expect(card!.className).toContain("min-w-0");
expect(card!.className).toContain("overflow-hidden");
});
describe("when selected and showing config", () => {
beforeEach(() => {
mockSelectedProjectId = "test-1";
});
it("expanded area has min-w-0 and overflow-hidden", () => {
const { container } = render(<ProjectCard project={mockProject} />);
// The expanded section (mt-2 ml-4) contains the auth/action/config controls
const expandedSection = container.querySelector(".ml-4.mt-2");
expect(expandedSection).not.toBeNull();
expect(expandedSection!.className).toContain("min-w-0");
expect(expandedSection!.className).toContain("overflow-hidden");
});
it("folder path inputs use min-w-0 to allow shrinking", async () => {
const { container } = render(<ProjectCard project={mockProject} />);
// Click Config button to show config panel
await act(async () => {
fireEvent.click(screen.getByText("Config"));
});
// After config is shown, check the folder host_path input has min-w-0
const hostPathInputs = container.querySelectorAll('input[placeholder="/path/to/folder"]');
expect(hostPathInputs.length).toBeGreaterThan(0);
expect(hostPathInputs[0].className).toContain("min-w-0");
});
it("config panel container has overflow-hidden", async () => {
const { container } = render(<ProjectCard project={mockProject} />);
// Click Config button
await act(async () => {
fireEvent.click(screen.getByText("Config"));
});
// The config panel has border-t and overflow containment classes
const allDivs = container.querySelectorAll("div");
const configPanel = Array.from(allDivs).find(
(div) => div.className.includes("border-t") && div.className.includes("min-w-0")
);
expect(configPanel).toBeDefined();
expect(configPanel!.className).toContain("overflow-hidden");
});
});
});

View File

@@ -1,9 +1,15 @@
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 { useProjects } from "../../hooks/useProjects";
import { useMcpServers } from "../../hooks/useMcpServers";
import { useTerminal } from "../../hooks/useTerminal";
import { useAppState } from "../../store/appState";
import EnvVarsModal from "./EnvVarsModal";
import PortMappingsModal from "./PortMappingsModal";
import ClaudeInstructionsModal from "./ClaudeInstructionsModal";
import ContainerProgressModal from "./ContainerProgressModal";
interface Props {
project: Project;
@@ -13,10 +19,17 @@ export default function ProjectCard({ project }: Props) {
const selectedProjectId = useAppState(s => s.selectedProjectId);
const setSelectedProject = useAppState(s => s.setSelectedProject);
const { start, stop, rebuild, remove, update } = useProjects();
const { mcpServers } = useMcpServers();
const { open: openTerminal } = useTerminal();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfig, setShowConfig] = useState(false);
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
const [showPortMappingsModal, setShowPortMappingsModal] = useState(false);
const [showClaudeInstructionsModal, setShowClaudeInstructionsModal] = useState(false);
const [progressMsg, setProgressMsg] = useState<string | null>(null);
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
const [operationCompleted, setOperationCompleted] = useState(false);
const isSelected = selectedProjectId === project.id;
const isStopped = project.status === "stopped" || project.status === "error";
@@ -28,6 +41,7 @@ export default function ProjectCard({ project }: Props) {
const [gitToken, setGitToken] = useState(project.git_token ?? "");
const [claudeInstructions, setClaudeInstructions] = useState(project.claude_instructions ?? "");
const [envVars, setEnvVars] = useState(project.custom_env_vars ?? []);
const [portMappings, setPortMappings] = useState(project.port_mappings ?? []);
// Bedrock local state for text fields
const [bedrockRegion, setBedrockRegion] = useState(project.bedrock_config?.aws_region ?? "us-east-1");
@@ -47,6 +61,7 @@ export default function ProjectCard({ project }: Props) {
setGitToken(project.git_token ?? "");
setClaudeInstructions(project.claude_instructions ?? "");
setEnvVars(project.custom_env_vars ?? []);
setPortMappings(project.port_mappings ?? []);
setBedrockRegion(project.bedrock_config?.aws_region ?? "us-east-1");
setBedrockAccessKeyId(project.bedrock_config?.aws_access_key_id ?? "");
setBedrockSecretKey(project.bedrock_config?.aws_secret_access_key ?? "");
@@ -56,9 +71,38 @@ export default function ProjectCard({ project }: Props) {
setBedrockModelId(project.bedrock_config?.model_id ?? "");
}, [project]);
// Listen for container progress events
useEffect(() => {
const unlisten = listen<{ project_id: string; message: string }>(
"container-progress",
(event) => {
if (event.payload.project_id === project.id) {
setProgressMsg(event.payload.message);
}
}
);
return () => { unlisten.then((f) => f()); };
}, [project.id]);
// Mark operation completed when status settles
useEffect(() => {
if (project.status === "running" || project.status === "stopped" || project.status === "error") {
if (activeOperation) {
setOperationCompleted(true);
}
// Clear progress if no modal is managing it
if (!activeOperation) {
setProgressMsg(null);
}
}
}, [project.status, activeOperation]);
const handleStart = async () => {
setLoading(true);
setError(null);
setProgressMsg(null);
setOperationCompleted(false);
setActiveOperation("starting");
try {
await start(project.id);
} catch (e) {
@@ -71,6 +115,9 @@ export default function ProjectCard({ project }: Props) {
const handleStop = async () => {
setLoading(true);
setError(null);
setProgressMsg(null);
setOperationCompleted(false);
setActiveOperation("stopping");
try {
await stop(project.id);
} catch (e) {
@@ -88,6 +135,21 @@ export default function ProjectCard({ project }: Props) {
}
};
const handleForceStop = async () => {
try {
await stop(project.id);
} catch (e) {
setError(String(e));
}
};
const closeModal = () => {
setActiveOperation(null);
setOperationCompleted(false);
setProgressMsg(null);
setError(null);
};
const defaultBedrockConfig: BedrockConfig = {
auth_method: "static_credentials",
aws_region: "us-east-1",
@@ -165,22 +227,6 @@ export default function ProjectCard({ project }: Props) {
}
};
const handleClaudeInstructionsBlur = async () => {
try {
await update({ ...project, claude_instructions: claudeInstructions || null });
} catch (err) {
console.error("Failed to update Claude instructions:", err);
}
};
const handleEnvVarBlur = async () => {
try {
await update({ ...project, custom_env_vars: envVars });
} catch (err) {
console.error("Failed to update environment variables:", err);
}
};
const handleBedrockRegionBlur = async () => {
try {
const current = project.bedrock_config ?? defaultBedrockConfig;
@@ -255,7 +301,7 @@ export default function ProjectCard({ project }: Props) {
return (
<div
onClick={() => setSelectedProject(project.id)}
className={`px-3 py-2 rounded cursor-pointer transition-colors ${
className={`px-3 py-2 rounded cursor-pointer transition-colors min-w-0 overflow-hidden ${
isSelected
? "bg-[var(--bg-tertiary)]"
: "hover:bg-[var(--bg-tertiary)]"
@@ -269,38 +315,25 @@ export default function ProjectCard({ project }: Props) {
{project.paths.map((pp, i) => (
<div key={i} className="text-xs text-[var(--text-secondary)] truncate">
<span className="font-mono">/workspace/{pp.mount_name}</span>
<span className="mx-1">&larr;</span>
<span>{pp.host_path}</span>
</div>
))}
</div>
{isSelected && (
<div className="mt-2 ml-4 space-y-2">
<div className="mt-2 ml-4 space-y-2 min-w-0 overflow-hidden">
{/* Auth mode 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("login"); }}
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("anthropic"); }}
disabled={!isStopped}
className={`px-2 py-0.5 rounded transition-colors ${
project.auth_mode === "login"
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`}
>
/login
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("api_key"); }}
disabled={!isStopped}
className={`px-2 py-0.5 rounded transition-colors ${
project.auth_mode === "api_key"
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
} disabled:opacity-50`}
>
API key
Anthropic
</button>
<button
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }}
@@ -323,6 +356,10 @@ export default function ProjectCard({ project }: Props) {
<ActionButton
onClick={async () => {
setLoading(true);
setError(null);
setProgressMsg(null);
setOperationCompleted(false);
setActiveOperation("resetting");
try { await rebuild(project.id); } catch (e) { setError(String(e)); }
setLoading(false);
}}
@@ -336,9 +373,12 @@ export default function ProjectCard({ project }: Props) {
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
</>
) : (
<span className="text-xs text-[var(--text-secondary)]">
{project.status}...
</span>
<>
<span className="text-xs text-[var(--text-secondary)]">
{progressMsg ?? `${project.status}...`}
</span>
<ActionButton onClick={handleStop} disabled={loading} label="Force Stop" danger />
</>
)}
<ActionButton
onClick={(e) => { e?.stopPropagation?.(); setShowConfig(!showConfig); }}
@@ -359,78 +399,87 @@ export default function ProjectCard({ project }: Props) {
{/* Config panel */}
{showConfig && (
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]" onClick={(e) => e.stopPropagation()}>
<div className="space-y-2 pt-1 border-t border-[var(--border-color)] min-w-0 overflow-hidden" onClick={(e) => e.stopPropagation()}>
{!isStopped && (
<div className="px-2 py-1.5 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
Container must be stopped to change settings.
</div>
)}
{/* Folder paths */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Folders</label>
{paths.map((pp, i) => (
<div key={i} className="flex gap-1 mb-1 items-center">
<input
value={pp.host_path}
onChange={(e) => {
const updated = [...paths];
updated[i] = { ...updated[i], host_path: e.target.value };
setPaths(updated);
}}
onBlur={async () => {
try { await update({ ...project, paths }); } catch (err) {
console.error("Failed to update paths:", err);
}
}}
placeholder="/path/to/folder"
disabled={!isStopped}
className="flex-1 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"
/>
<button
onClick={async () => {
const selected = await open({ directory: true, multiple: false });
if (typeof selected === "string") {
<div key={i} className="mb-1">
<div className="flex gap-1 items-center min-w-0">
<input
value={pp.host_path}
onChange={(e) => {
const updated = [...paths];
const basename = selected.replace(/[/\\]$/, "").split(/[/\\]/).pop() || "";
updated[i] = { host_path: selected, mount_name: updated[i].mount_name || basename };
updated[i] = { ...updated[i], host_path: e.target.value };
setPaths(updated);
try { await update({ ...project, paths: updated }); } catch (err) {
}}
onBlur={async () => {
try { await update({ ...project, paths }); } catch (err) {
console.error("Failed to update paths:", err);
}
}
}}
disabled={!isStopped}
className="px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
>
...
</button>
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">/workspace/</span>
<input
value={pp.mount_name}
onChange={(e) => {
const updated = [...paths];
updated[i] = { ...updated[i], mount_name: e.target.value };
setPaths(updated);
}}
onBlur={async () => {
try { await update({ ...project, paths }); } catch (err) {
console.error("Failed to update paths:", err);
}
}}
placeholder="name"
disabled={!isStopped}
className="w-20 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 font-mono"
/>
{paths.length > 1 && (
}}
placeholder="/path/to/folder"
disabled={!isStopped}
className="flex-1 min-w-0 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"
/>
<button
onClick={async () => {
const updated = paths.filter((_, j) => j !== i);
setPaths(updated);
try { await update({ ...project, paths: updated }); } catch (err) {
console.error("Failed to remove path:", err);
const selected = await open({ directory: true, multiple: false });
if (typeof selected === "string") {
const updated = [...paths];
const basename = selected.replace(/[/\\]$/, "").split(/[/\\]/).pop() || "";
updated[i] = { host_path: selected, mount_name: updated[i].mount_name || basename };
setPaths(updated);
try { await update({ ...project, paths: updated }); } catch (err) {
console.error("Failed to update paths:", err);
}
}
}}
disabled={!isStopped}
className="px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
className="flex-shrink-0 px-2 py-1 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] disabled:opacity-50 transition-colors"
>
x
...
</button>
)}
{paths.length > 1 && (
<button
onClick={async () => {
const updated = paths.filter((_, j) => j !== i);
setPaths(updated);
try { await update({ ...project, paths: updated }); } catch (err) {
console.error("Failed to remove path:", err);
}
}}
disabled={!isStopped}
className="flex-shrink-0 px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
>
x
</button>
)}
</div>
<div className="flex gap-1 items-center mt-0.5 min-w-0">
<span className="text-xs text-[var(--text-secondary)]">/workspace/</span>
<input
value={pp.mount_name}
onChange={(e) => {
const updated = [...paths];
updated[i] = { ...updated[i], mount_name: e.target.value };
setPaths(updated);
}}
onBlur={async () => {
try { await update({ ...project, paths }); } catch (err) {
console.error("Failed to update paths:", err);
}
}}
placeholder="name"
disabled={!isStopped}
className="flex-1 min-w-0 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 font-mono"
/>
</div>
</div>
))}
<button
@@ -528,78 +577,87 @@ export default function ProjectCard({ project }: Props) {
</div>
{/* Environment Variables */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Environment Variables</label>
{envVars.map((ev, i) => (
<div key={i} className="flex gap-1 mb-1">
<input
value={ev.key}
onChange={(e) => {
const vars = [...envVars];
vars[i] = { ...vars[i], key: e.target.value };
setEnvVars(vars);
}}
onBlur={handleEnvVarBlur}
placeholder="KEY"
disabled={!isStopped}
className="w-1/3 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 font-mono"
/>
<input
value={ev.value}
onChange={(e) => {
const vars = [...envVars];
vars[i] = { ...vars[i], value: e.target.value };
setEnvVars(vars);
}}
onBlur={handleEnvVarBlur}
placeholder="value"
disabled={!isStopped}
className="flex-1 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 font-mono"
/>
<button
onClick={async () => {
const vars = envVars.filter((_, j) => j !== i);
setEnvVars(vars);
try { await update({ ...project, custom_env_vars: vars }); } catch (err) {
console.error("Failed to remove environment variable:", err);
}
}}
disabled={!isStopped}
className="px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
>
x
</button>
</div>
))}
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">
Environment Variables{envVars.length > 0 && ` (${envVars.length})`}
</label>
<button
onClick={async () => {
const vars = [...envVars, { key: "", value: "" }];
setEnvVars(vars);
try { await update({ ...project, custom_env_vars: vars }); } catch (err) {
console.error("Failed to add environment variable:", err);
}
}}
disabled={!isStopped}
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
onClick={() => setShowEnvVarsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
+ Add variable
Edit
</button>
</div>
{/* Port Mappings */}
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">
Port Mappings{portMappings.length > 0 && ` (${portMappings.length})`}
</label>
<button
onClick={() => setShowPortMappingsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
{/* Claude Instructions */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Claude Instructions</label>
<textarea
value={claudeInstructions}
onChange={(e) => setClaudeInstructions(e.target.value)}
onBlur={handleClaudeInstructionsBlur}
placeholder="Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)"
disabled={!isStopped}
rows={3}
className="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 resize-y font-mono"
/>
<div className="flex items-center justify-between">
<label className="text-xs text-[var(--text-secondary)]">
Claude Instructions{claudeInstructions ? " (set)" : ""}
</label>
<button
onClick={() => setShowClaudeInstructionsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
{/* MCP Servers */}
{mcpServers.length > 0 && (
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-1">MCP Servers</label>
<div className="space-y-1">
{mcpServers.map((server) => {
const enabled = project.enabled_mcp_servers.includes(server.id);
const isDocker = !!server.docker_image;
return (
<label key={server.id} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={enabled}
disabled={!isStopped}
onChange={async () => {
const updated = enabled
? project.enabled_mcp_servers.filter((id) => id !== server.id)
: [...project.enabled_mcp_servers, server.id];
try {
await update({ ...project, enabled_mcp_servers: updated });
} catch (err) {
console.error("Failed to update MCP servers:", err);
}
}}
className="rounded border-[var(--border-color)] disabled:opacity-50"
/>
<span className="text-xs text-[var(--text-primary)]">{server.name}</span>
<span className="text-xs text-[var(--text-secondary)]">({server.transport_type})</span>
<span className={`text-xs px-1 py-0.5 rounded ${isDocker ? "bg-blue-500/20 text-blue-400" : "bg-[var(--bg-secondary)] text-[var(--text-secondary)]"}`}>
{isDocker ? "Docker" : "Manual"}
</span>
</label>
);
})}
</div>
{mcpServers.some((s) => s.docker_image && s.transport_type === "stdio" && project.enabled_mcp_servers.includes(s.id)) && (
<p className="text-xs text-[var(--text-secondary)] mt-1 opacity-70">
Docker access will be auto-enabled for stdio+Docker MCP servers.
</p>
)}
</div>
)}
{/* Bedrock config */}
{project.auth_mode === "bedrock" && (() => {
const bc = project.bedrock_config ?? defaultBedrockConfig;
@@ -732,6 +790,54 @@ export default function ProjectCard({ project }: Props) {
{error && (
<div className="text-xs text-[var(--error)] mt-1 ml-4">{error}</div>
)}
{showEnvVarsModal && (
<EnvVarsModal
envVars={envVars}
disabled={!isStopped}
onSave={async (vars) => {
setEnvVars(vars);
await update({ ...project, custom_env_vars: vars });
}}
onClose={() => setShowEnvVarsModal(false)}
/>
)}
{showPortMappingsModal && (
<PortMappingsModal
portMappings={portMappings}
disabled={!isStopped}
onSave={async (mappings) => {
setPortMappings(mappings);
await update({ ...project, port_mappings: mappings });
}}
onClose={() => setShowPortMappingsModal(false)}
/>
)}
{showClaudeInstructionsModal && (
<ClaudeInstructionsModal
instructions={claudeInstructions}
disabled={!isStopped}
onSave={async (instructions) => {
setClaudeInstructions(instructions);
await update({ ...project, claude_instructions: instructions || null });
}}
onClose={() => setShowClaudeInstructionsModal(false)}
/>
)}
{activeOperation && (
<ContainerProgressModal
projectName={project.name}
operation={activeOperation}
progressMsg={progressMsg}
error={error}
completed={operationCompleted}
onForceStop={handleForceStop}
onClose={closeModal}
/>
)}
</div>
);
}

View File

@@ -1,68 +1,10 @@
import { useState } from "react";
import { useSettings } from "../../hooks/useSettings";
export default function ApiKeyInput() {
const { hasKey, saveApiKey, removeApiKey } = useSettings();
const [key, setKey] = useState("");
const [error, setError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const handleSave = async () => {
if (!key.trim()) return;
setSaving(true);
setError(null);
try {
await saveApiKey(key.trim());
setKey("");
} catch (e) {
setError(String(e));
} finally {
setSaving(false);
}
};
return (
<div>
<label className="block text-sm font-medium mb-1">Authentication</label>
<p className="text-xs text-[var(--text-secondary)] mb-3">
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal), an <strong>API key</strong>, 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 auth mode per-project.
</p>
<label className="block text-xs text-[var(--text-secondary)] mb-1 mt-3">
API Key (for projects using API key mode)
</label>
{hasKey ? (
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--success)]">Key configured</span>
<button
onClick={async () => {
try { await removeApiKey(); } catch (e) { setError(String(e)); }
}}
className="text-xs text-[var(--error)] hover:underline"
>
Remove
</button>
</div>
) : (
<div className="space-y-2">
<input
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
placeholder="sk-ant-..."
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
onKeyDown={(e) => e.key === "Enter" && handleSave()}
/>
<button
onClick={handleSave}
disabled={saving || !key.trim()}
className="px-3 py-1.5 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
{saving ? "Saving..." : "Save Key"}
</button>
</div>
)}
{error && <div className="text-xs text-[var(--error)] mt-1">{error}</div>}
</div>
);
}

View File

@@ -51,6 +51,7 @@ export default function AwsSettings() {
<div className="space-y-3 text-sm">
<p className="text-xs text-[var(--text-secondary)]">
Global AWS defaults for Bedrock projects. Per-project settings override these.
Changes here require a container rebuild to take effect.
</p>
{/* AWS Config Path */}

View File

@@ -4,22 +4,37 @@ import DockerSettings from "./DockerSettings";
import AwsSettings from "./AwsSettings";
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";
export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings();
const { appVersion, checkForUpdates } = useUpdates();
const [globalInstructions, setGlobalInstructions] = useState(appSettings?.global_claude_instructions ?? "");
const [globalEnvVars, setGlobalEnvVars] = useState<EnvVar[]>(appSettings?.global_custom_env_vars ?? []);
const [checkingUpdates, setCheckingUpdates] = useState(false);
const [timezone, setTimezone] = useState(appSettings?.timezone ?? "");
const [showInstructionsModal, setShowInstructionsModal] = useState(false);
const [showEnvVarsModal, setShowEnvVarsModal] = useState(false);
// Sync local state when appSettings change
useEffect(() => {
setGlobalInstructions(appSettings?.global_claude_instructions ?? "");
}, [appSettings?.global_claude_instructions]);
setGlobalEnvVars(appSettings?.global_custom_env_vars ?? []);
setTimezone(appSettings?.timezone ?? "");
}, [appSettings?.global_claude_instructions, appSettings?.global_custom_env_vars, appSettings?.timezone]);
const handleInstructionsBlur = async () => {
if (!appSettings) return;
await saveSettings({ ...appSettings, global_claude_instructions: globalInstructions || null });
};
// Auto-detect timezone on first load if not yet set
useEffect(() => {
if (appSettings && !appSettings.timezone) {
detectHostTimezone().then((tz) => {
setTimezone(tz);
saveSettings({ ...appSettings, timezone: tz });
}).catch(() => {});
}
}, [appSettings?.timezone]);
const handleCheckNow = async () => {
setCheckingUpdates(true);
@@ -43,19 +58,63 @@ export default function SettingsPanel() {
<ApiKeyInput />
<DockerSettings />
<AwsSettings />
{/* Container Timezone */}
<div>
<label className="block text-sm font-medium mb-2">Claude Instructions</label>
<label className="block text-sm font-medium mb-1">Container Timezone</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>
<input
type="text"
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
onBlur={async () => {
if (appSettings) {
await saveSettings({ ...appSettings, timezone: timezone || null });
}
}}
placeholder="UTC"
className="w-full px-2 py-1 text-sm bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)]"
/>
</div>
{/* Global Claude Instructions */}
<div>
<label className="block text-sm font-medium mb-1">Claude Instructions</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>
<textarea
value={globalInstructions}
onChange={(e) => setGlobalInstructions(e.target.value)}
onBlur={handleInstructionsBlur}
placeholder="Instructions for Claude Code in all project containers..."
rows={4}
className="w-full px-2 py-1.5 text-xs bg-[var(--bg-primary)] border border-[var(--border-color)] rounded focus:outline-none focus:border-[var(--accent)] resize-y font-mono"
/>
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--text-secondary)]">
{globalInstructions ? "Configured" : "Not set"}
</span>
<button
onClick={() => setShowInstructionsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
</div>
{/* Global Environment Variables */}
<div>
<label className="block text-sm font-medium mb-1">Global Environment Variables</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>
<div className="flex items-center justify-between">
<span className="text-xs text-[var(--text-secondary)]">
{globalEnvVars.length > 0 ? `${globalEnvVars.length} variable${globalEnvVars.length === 1 ? "" : "s"}` : "None"}
</span>
<button
onClick={() => setShowEnvVarsModal(true)}
className="text-xs px-2 py-0.5 text-[var(--accent)] hover:text-[var(--accent-hover)] hover:bg-[var(--bg-primary)] rounded transition-colors"
>
Edit
</button>
</div>
</div>
{/* Updates section */}
@@ -89,6 +148,34 @@ export default function SettingsPanel() {
</button>
</div>
</div>
{showInstructionsModal && (
<ClaudeInstructionsModal
instructions={globalInstructions}
disabled={false}
onSave={async (instructions) => {
setGlobalInstructions(instructions);
if (appSettings) {
await saveSettings({ ...appSettings, global_claude_instructions: instructions || null });
}
}}
onClose={() => setShowInstructionsModal(false)}
/>
)}
{showEnvVarsModal && (
<EnvVarsModal
envVars={globalEnvVars}
disabled={false}
onSave={async (vars) => {
setGlobalEnvVars(vars);
if (appSettings) {
await saveSettings({ ...appSettings, global_custom_env_vars: vars });
}
}}
onClose={() => setShowEnvVarsModal(false)}
/>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Terminal } from "@xterm/xterm";
import { FitAddon } from "@xterm/addon-fit";
import { WebglAddon } from "@xterm/addon-webgl";
@@ -6,11 +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";
/** Strip ANSI escape sequences from a string. */
function stripAnsi(s: string): string {
return s.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07/g, "");
}
import { UrlDetector } from "../../lib/urlDetector";
import UrlToast from "./UrlToast";
interface Props {
sessionId: string;
@@ -19,10 +16,16 @@ interface Props {
export default function TerminalView({ sessionId, active }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const terminalContainerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<Terminal | null>(null);
const fitRef = useRef<FitAddon | null>(null);
const webglRef = useRef<WebglAddon | null>(null);
const { sendInput, resize, onOutput, onExit } = useTerminal();
const detectorRef = useRef<UrlDetector | null>(null);
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
const [isAtBottom, setIsAtBottom] = useState(true);
useEffect(() => {
if (!containerRef.current) return;
@@ -84,50 +87,56 @@ export default function TerminalView({ sessionId, active }: Props) {
sendInput(sessionId, data);
});
// ── URL accumulator ──────────────────────────────────────────────
// Claude Code login emits a long OAuth URL that gets split across
// hard newlines (\n / \r\n). The WebLinksAddon only joins
// soft-wrapped lines (the `isWrapped` flag), so the URL match is
// truncated and the link fails when clicked.
//
// Fix: buffer recent output, strip ANSI codes, and after a short
// debounce check for a URL that spans multiple lines. When found,
// write a single clean clickable copy to the terminal.
const textDecoder = new TextDecoder();
let outputBuffer = "";
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Track scroll position to show "Jump to Current" button
const scrollDisposable = term.onScroll(() => {
const buf = term.buffer.active;
setIsAtBottom(buf.viewportY >= buf.baseY);
});
const flushUrlBuffer = () => {
const plain = stripAnsi(outputBuffer);
// Reassemble: strip hard newlines and carriage returns to join
// fragments that were split across terminal lines.
const joined = plain.replace(/[\r\n]+/g, "");
// Look for a long OAuth/auth URL (Claude login URLs contain
// "oauth" or "console.anthropic.com" or "/authorize").
const match = joined.match(/https?:\/\/[^\s'"\x07]{80,}/);
if (match) {
const url = match[0];
term.write("\r\n\x1b[36m🔗 Clickable login URL:\x1b[0m\r\n");
term.write(`\x1b[4;34m${url}\x1b[0m\r\n`);
// Handle image paste: intercept paste events with image data,
// upload to the container, and inject the file path into terminal input.
const handlePaste = (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of Array.from(items)) {
if (item.type.startsWith("image/")) {
e.preventDefault();
e.stopPropagation();
const blob = item.getAsFile();
if (!blob) return;
blob.arrayBuffer().then(async (buf) => {
try {
setImagePasteMsg("Uploading image...");
const data = new Uint8Array(buf);
const filePath = await pasteImage(sessionId, data);
// Inject the file path into terminal stdin
sendInput(sessionId, filePath);
setImagePasteMsg(`Image saved to ${filePath}`);
} catch (err) {
console.error("Image paste failed:", err);
setImagePasteMsg("Image paste failed");
}
});
return; // Only handle the first image
}
}
outputBuffer = "";
};
containerRef.current.addEventListener("paste", handlePaste, { capture: true });
// Handle backend output -> terminal
let aborted = false;
const detector = new UrlDetector((url) => setDetectedUrl(url));
detectorRef.current = detector;
const outputPromise = onOutput(sessionId, (data) => {
if (aborted) return;
term.write(data);
// Accumulate for URL detection (data is a Uint8Array, so decode it)
outputBuffer += textDecoder.decode(data);
// Cap buffer size to avoid memory growth
if (outputBuffer.length > 8192) {
outputBuffer = outputBuffer.slice(-4096);
}
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(flushUrlBuffer, 150);
detector.feed(data);
}).then((unlisten) => {
if (aborted) unlisten();
return unlisten;
@@ -159,8 +168,11 @@ export default function TerminalView({ sessionId, active }: Props) {
return () => {
aborted = true;
if (debounceTimer) clearTimeout(debounceTimer);
detector.dispose();
detectorRef.current = null;
inputDisposable.dispose();
scrollDisposable.dispose();
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
outputPromise.then((fn) => fn?.());
exitPromise.then((fn) => fn?.());
if (resizeRafId !== null) cancelAnimationFrame(resizeRafId);
@@ -204,11 +216,67 @@ export default function TerminalView({ sessionId, active }: Props) {
}
}, [active]);
// Auto-dismiss toast after 30 seconds
useEffect(() => {
if (!detectedUrl) return;
const timer = setTimeout(() => setDetectedUrl(null), 30_000);
return () => clearTimeout(timer);
}, [detectedUrl]);
// Auto-dismiss image paste message after 3 seconds
useEffect(() => {
if (!imagePasteMsg) return;
const timer = setTimeout(() => setImagePasteMsg(null), 3_000);
return () => clearTimeout(timer);
}, [imagePasteMsg]);
const handleOpenUrl = useCallback(() => {
if (detectedUrl) {
openUrl(detectedUrl).catch((e) =>
console.error("Failed to open URL:", e),
);
setDetectedUrl(null);
}
}, [detectedUrl]);
const handleScrollToBottom = useCallback(() => {
termRef.current?.scrollToBottom();
setIsAtBottom(true);
}, []);
return (
<div
ref={containerRef}
className={`w-full h-full ${active ? "" : "hidden"}`}
style={{ padding: "8px" }}
/>
ref={terminalContainerRef}
className={`w-full h-full relative ${active ? "" : "hidden"}`}
>
{detectedUrl && (
<UrlToast
url={detectedUrl}
onOpen={handleOpenUrl}
onDismiss={() => setDetectedUrl(null)}
/>
)}
{imagePasteMsg && (
<div
className="absolute top-2 left-1/2 -translate-x-1/2 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#e6edf3] border border-[#30363d] shadow-lg"
onClick={() => setImagePasteMsg(null)}
>
{imagePasteMsg}
</div>
)}
{!isAtBottom && (
<button
onClick={handleScrollToBottom}
className="absolute bottom-4 right-4 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#58a6ff] border border-[#30363d] shadow-lg hover:bg-[#2d3748] transition-colors cursor-pointer"
>
Jump to Current
</button>
)}
<div
ref={containerRef}
className="w-full h-full"
style={{ padding: "8px" }}
/>
</div>
);
}

View File

@@ -0,0 +1,101 @@
interface Props {
url: string;
onOpen: () => void;
onDismiss: () => void;
}
export default function UrlToast({ url, onOpen, onDismiss }: Props) {
return (
<div
className="animate-slide-down"
style={{
position: "absolute",
top: 12,
left: "50%",
transform: "translateX(-50%)",
zIndex: 40,
display: "flex",
alignItems: "center",
gap: 10,
padding: "8px 12px",
background: "var(--bg-secondary)",
border: "1px solid var(--border-color)",
borderRadius: 8,
boxShadow: "0 4px 12px rgba(0,0,0,0.4)",
maxWidth: "min(90%, 600px)",
}}
>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 12,
color: "var(--text-secondary)",
marginBottom: 2,
}}
>
Long URL detected
</div>
<div
style={{
fontSize: 12,
fontFamily: "monospace",
color: "var(--text-primary)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{url}
</div>
</div>
<button
onClick={onOpen}
style={{
padding: "4px 12px",
fontSize: 12,
fontWeight: 600,
color: "#fff",
background: "var(--accent)",
border: "none",
borderRadius: 4,
cursor: "pointer",
whiteSpace: "nowrap",
flexShrink: 0,
}}
onMouseEnter={(e) =>
(e.currentTarget.style.background = "var(--accent-hover)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.background = "var(--accent)")
}
>
Open
</button>
<button
onClick={onDismiss}
style={{
padding: "2px 6px",
fontSize: 14,
lineHeight: 1,
color: "var(--text-secondary)",
background: "transparent",
border: "none",
borderRadius: 4,
cursor: "pointer",
flexShrink: 0,
}}
onMouseEnter={(e) =>
(e.currentTarget.style.color = "var(--text-primary)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.color = "var(--text-secondary)")
}
aria-label="Dismiss"
>
</button>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import { useShallow } from "zustand/react/shallow";
import { listen } from "@tauri-apps/api/event";
import { useAppState } from "../store/appState";
@@ -59,6 +59,39 @@ export function useDocker() {
[setImageExists],
);
const pollingRef = useRef<ReturnType<typeof setInterval> | null>(null);
const startDockerPolling = useCallback(() => {
// Don't start if already polling
if (pollingRef.current) return () => {};
const interval = setInterval(async () => {
try {
const available = await commands.checkDocker();
if (available) {
clearInterval(interval);
pollingRef.current = null;
setDockerAvailable(true);
// Also check image once Docker is available
try {
const exists = await commands.checkImageExists();
setImageExists(exists);
} catch {
setImageExists(false);
}
}
} catch {
// Still not available, keep polling
}
}, 5000);
pollingRef.current = interval;
return () => {
clearInterval(interval);
pollingRef.current = null;
};
}, [setDockerAvailable, setImageExists]);
const pullImage = useCallback(
async (imageName: string, onProgress?: (msg: string) => void) => {
const unlisten = onProgress
@@ -84,5 +117,6 @@ export function useDocker() {
checkImage,
buildImage,
pullImage,
startDockerPolling,
};
}

View File

@@ -0,0 +1,55 @@
import { useCallback } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../store/appState";
import * as commands from "../lib/tauri-commands";
import type { McpServer } from "../lib/types";
export function useMcpServers() {
const {
mcpServers,
setMcpServers,
updateMcpServerInList,
removeMcpServerFromList,
} = useAppState(
useShallow(s => ({
mcpServers: s.mcpServers,
setMcpServers: s.setMcpServers,
updateMcpServerInList: s.updateMcpServerInList,
removeMcpServerFromList: s.removeMcpServerFromList,
}))
);
const refresh = useCallback(async () => {
const list = await commands.listMcpServers();
setMcpServers(list);
}, [setMcpServers]);
const add = useCallback(
async (name: string) => {
const server = await commands.addMcpServer(name);
const list = await commands.listMcpServers();
setMcpServers(list);
return server;
},
[setMcpServers],
);
const update = useCallback(
async (server: McpServer) => {
const updated = await commands.updateMcpServer(server);
updateMcpServerInList(updated);
return updated;
},
[updateMcpServerInList],
);
const remove = useCallback(
async (id: string) => {
await commands.removeMcpServer(id);
removeMcpServerFromList(id);
},
[removeMcpServerFromList],
);
return { mcpServers, refresh, add, update, remove };
}

View File

@@ -50,31 +50,45 @@ export function useProjects() {
[removeProjectFromList],
);
const setOptimisticStatus = useCallback(
(id: string, status: "starting" | "stopping") => {
const { projects } = useAppState.getState();
const project = projects.find((p) => p.id === id);
if (project) {
updateProjectInList({ ...project, status });
}
},
[updateProjectInList],
);
const start = useCallback(
async (id: string) => {
setOptimisticStatus(id, "starting");
const updated = await commands.startProjectContainer(id);
updateProjectInList(updated);
return updated;
},
[updateProjectInList],
[updateProjectInList, setOptimisticStatus],
);
const stop = useCallback(
async (id: string) => {
setOptimisticStatus(id, "stopping");
await commands.stopProjectContainer(id);
const list = await commands.listProjects();
setProjects(list);
},
[setProjects],
[setProjects, setOptimisticStatus],
);
const rebuild = useCallback(
async (id: string) => {
setOptimisticStatus(id, "starting");
const updated = await commands.rebuildProjectContainer(id);
updateProjectInList(updated);
return updated;
},
[updateProjectInList],
[updateProjectInList, setOptimisticStatus],
);
const update = useCallback(

View File

@@ -5,39 +5,13 @@ import * as commands from "../lib/tauri-commands";
import type { AppSettings } from "../lib/types";
export function useSettings() {
const { hasKey, setHasKey, appSettings, setAppSettings } = useAppState(
const { appSettings, setAppSettings } = useAppState(
useShallow(s => ({
hasKey: s.hasKey,
setHasKey: s.setHasKey,
appSettings: s.appSettings,
setAppSettings: s.setAppSettings,
}))
);
const checkApiKey = useCallback(async () => {
try {
const has = await commands.hasApiKey();
setHasKey(has);
return has;
} catch {
setHasKey(false);
return false;
}
}, [setHasKey]);
const saveApiKey = useCallback(
async (key: string) => {
await commands.setApiKey(key);
setHasKey(true);
},
[setHasKey],
);
const removeApiKey = useCallback(async () => {
await commands.deleteApiKey();
setHasKey(false);
}, [setHasKey]);
const loadSettings = useCallback(async () => {
try {
const settings = await commands.getSettings();
@@ -59,10 +33,6 @@ export function useSettings() {
);
return {
hasKey,
checkApiKey,
saveApiKey,
removeApiKey,
appSettings,
loadSettings,
saveSettings,

View File

@@ -49,6 +49,14 @@ export function useTerminal() {
[],
);
const pasteImage = useCallback(
async (sessionId: string, imageData: Uint8Array) => {
const bytes = Array.from(imageData);
return commands.pasteImageToTerminal(sessionId, bytes);
},
[],
);
const onOutput = useCallback(
(sessionId: string, callback: (data: Uint8Array) => void) => {
const eventName = `terminal-output-${sessionId}`;
@@ -76,6 +84,7 @@ export function useTerminal() {
open,
close,
sendInput,
pasteImage,
resize,
onOutput,
onExit,

View File

@@ -46,3 +46,10 @@ body {
::-webkit-scrollbar-thumb:hover {
background: var(--border-color);
}
/* Toast slide-down animation */
@keyframes slide-down {
from { opacity: 0; transform: translate(-50%, -8px); }
to { opacity: 1; transform: translate(-50%, 0); }
}
.animate-slide-down { animation: slide-down 0.2s ease-out; }

View File

@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo } from "./types";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, McpServer } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -26,10 +26,6 @@ export const rebuildProjectContainer = (projectId: string) =>
invoke<Project>("rebuild_project_container", { projectId });
// Settings
export const setApiKey = (key: string) =>
invoke<void>("set_api_key", { key });
export const hasApiKey = () => invoke<boolean>("has_api_key");
export const deleteApiKey = () => invoke<void>("delete_api_key");
export const getSettings = () => invoke<AppSettings>("get_settings");
export const updateSettings = (settings: AppSettings) =>
invoke<AppSettings>("update_settings", { settings });
@@ -39,6 +35,8 @@ export const detectAwsConfig = () =>
invoke<string | null>("detect_aws_config");
export const listAwsProfiles = () =>
invoke<string[]>("list_aws_profiles");
export const detectHostTimezone = () =>
invoke<string>("detect_host_timezone");
// Terminal
export const openTerminalSession = (projectId: string, sessionId: string) =>
@@ -49,6 +47,17 @@ export const terminalResize = (sessionId: string, cols: number, rows: number) =>
invoke<void>("terminal_resize", { sessionId, cols, rows });
export const closeTerminalSession = (sessionId: string) =>
invoke<void>("close_terminal_session", { sessionId });
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
// MCP Servers
export const listMcpServers = () => invoke<McpServer[]>("list_mcp_servers");
export const addMcpServer = (name: string) =>
invoke<McpServer>("add_mcp_server", { name });
export const updateMcpServer = (server: McpServer) =>
invoke<McpServer>("update_mcp_server", { server });
export const removeMcpServer = (serverId: string) =>
invoke<void>("remove_mcp_server", { serverId });
// Updates
export const getAppVersion = () => invoke<string>("get_app_version");

View File

@@ -8,6 +8,12 @@ export interface ProjectPath {
mount_name: string;
}
export interface PortMapping {
host_port: number;
container_port: number;
protocol: string;
}
export interface Project {
id: string;
name: string;
@@ -22,7 +28,9 @@ export interface Project {
git_user_name: string | null;
git_user_email: string | null;
custom_env_vars: EnvVar[];
port_mappings: PortMapping[];
claude_instructions: string | null;
enabled_mcp_servers: string[];
created_at: string;
updated_at: string;
}
@@ -34,7 +42,7 @@ export type ProjectStatus =
| "stopping"
| "error";
export type AuthMode = "login" | "api_key" | "bedrock";
export type AuthMode = "anthropic" | "bedrock";
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
@@ -88,8 +96,10 @@ export interface AppSettings {
custom_image_name: string | null;
global_aws: GlobalAwsSettings;
global_claude_instructions: string | null;
global_custom_env_vars: EnvVar[];
auto_check_updates: boolean;
dismissed_update_version: string | null;
timezone: string | null;
}
export interface UpdateInfo {
@@ -106,3 +116,20 @@ export interface ReleaseAsset {
browser_download_url: string;
size: number;
}
export type McpTransportType = "stdio" | "http";
export interface McpServer {
id: string;
name: string;
transport_type: McpTransportType;
command: string | null;
args: string[];
env: Record<string, string>;
url: string | null;
headers: Record<string, string>;
docker_image: string | null;
container_port: number | null;
created_at: string;
updated_at: string;
}

127
app/src/lib/urlDetector.ts Normal file
View File

@@ -0,0 +1,127 @@
/**
* Detects long URLs that span multiple hard-wrapped lines in PTY output.
*
* The Linux PTY hard-wraps long lines with \r\n at the terminal column width,
* which breaks xterm.js WebLinksAddon URL detection. This class flattens
* the buffer (stripping PTY wraps, converting blank lines to spaces) and
* matches URLs with a single regex, firing a callback for ones >= 100 chars.
*
* When a URL match extends to the end of the flattened buffer, emission is
* deferred (more chunks may still be arriving). A confirmation timer emits
* the pending URL if no further data arrives within 500 ms.
*/
const ANSI_RE =
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g;
const MAX_BUFFER = 8 * 1024; // 8 KB rolling buffer cap
const DEBOUNCE_MS = 300;
const CONFIRM_MS = 500; // extra wait when URL reaches end of buffer
const MIN_URL_LENGTH = 100;
export type UrlCallback = (url: string) => void;
export class UrlDetector {
private decoder = new TextDecoder();
private buffer = "";
private timer: ReturnType<typeof setTimeout> | null = null;
private confirmTimer: ReturnType<typeof setTimeout> | null = null;
private lastEmitted = "";
private pendingUrl: string | null = null;
private callback: UrlCallback;
constructor(callback: UrlCallback) {
this.callback = callback;
}
/** Feed raw PTY output chunks. */
feed(data: Uint8Array): void {
this.buffer += this.decoder.decode(data, { stream: true });
// Cap buffer to avoid unbounded growth
if (this.buffer.length > MAX_BUFFER) {
this.buffer = this.buffer.slice(-MAX_BUFFER);
}
// Cancel pending timers — new data arrived, rescan from scratch
if (this.timer !== null) clearTimeout(this.timer);
if (this.confirmTimer !== null) {
clearTimeout(this.confirmTimer);
this.confirmTimer = null;
}
// Debounce — scan after 300 ms of silence
this.timer = setTimeout(() => {
this.timer = null;
this.scan();
}, DEBOUNCE_MS);
}
private scan(): void {
// 1. Strip ANSI escape sequences
const clean = this.buffer.replace(ANSI_RE, "");
// 2. Flatten the buffer:
// - Blank lines (2+ consecutive line breaks) → space (real paragraph break / URL terminator)
// - Remaining \r and \n → removed (PTY hard-wrap artifacts)
const flat = clean
.replace(/(\r?\n){2,}/g, " ")
.replace(/[\r\n]/g, "");
if (!flat) return;
// 3. Match URLs on the flattened string — spans across wrapped lines naturally
const urlRe = /https?:\/\/[^\s'"<>\x07]+/g;
let m: RegExpExecArray | null;
while ((m = urlRe.exec(flat)) !== null) {
const url = m[0];
// 4. Filter by length
if (url.length < MIN_URL_LENGTH) continue;
// 5. If the match extends to the very end of the flattened string,
// more chunks may still be arriving — defer emission.
if (m.index + url.length >= flat.length) {
this.pendingUrl = url;
this.confirmTimer = setTimeout(() => {
this.confirmTimer = null;
this.emitPending();
}, CONFIRM_MS);
return;
}
// 6. URL is clearly complete (more content follows) — dedup + emit
this.pendingUrl = null;
if (url !== this.lastEmitted) {
this.lastEmitted = url;
this.callback(url);
}
}
// Scan finished without a URL at the buffer end.
// If we had a pending URL from a previous scan, it's now confirmed complete.
if (this.pendingUrl) {
this.emitPending();
}
}
private emitPending(): void {
if (this.pendingUrl && this.pendingUrl !== this.lastEmitted) {
this.lastEmitted = this.pendingUrl;
this.callback(this.pendingUrl);
}
this.pendingUrl = null;
}
dispose(): void {
if (this.timer !== null) {
clearTimeout(this.timer);
this.timer = null;
}
if (this.confirmTimer !== null) {
clearTimeout(this.confirmTimer);
this.confirmTimer = null;
}
}
}

View File

@@ -1,5 +1,5 @@
import { create } from "zustand";
import type { Project, TerminalSession, AppSettings, UpdateInfo } from "../lib/types";
import type { Project, TerminalSession, AppSettings, UpdateInfo, McpServer } from "../lib/types";
interface AppState {
// Projects
@@ -17,16 +17,19 @@ interface AppState {
removeSession: (id: string) => void;
setActiveSession: (id: string | null) => void;
// MCP servers
mcpServers: McpServer[];
setMcpServers: (servers: McpServer[]) => void;
updateMcpServerInList: (server: McpServer) => void;
removeMcpServerFromList: (id: string) => void;
// UI state
sidebarView: "projects" | "settings";
setSidebarView: (view: "projects" | "settings") => void;
sidebarView: "projects" | "mcp" | "settings";
setSidebarView: (view: "projects" | "mcp" | "settings") => void;
dockerAvailable: boolean | null;
setDockerAvailable: (available: boolean | null) => void;
imageExists: boolean | null;
setImageExists: (exists: boolean | null) => void;
hasKey: boolean | null;
setHasKey: (has: boolean | null) => void;
// App settings
appSettings: AppSettings | null;
setAppSettings: (settings: AppSettings) => void;
@@ -78,6 +81,20 @@ export const useAppState = create<AppState>((set) => ({
}),
setActiveSession: (id) => set({ activeSessionId: id }),
// MCP servers
mcpServers: [],
setMcpServers: (servers) => set({ mcpServers: servers }),
updateMcpServerInList: (server) =>
set((state) => ({
mcpServers: state.mcpServers.map((s) =>
s.id === server.id ? server : s,
),
})),
removeMcpServerFromList: (id) =>
set((state) => ({
mcpServers: state.mcpServers.filter((s) => s.id !== id),
})),
// UI state
sidebarView: "projects",
setSidebarView: (view) => set({ sidebarView: view }),
@@ -85,9 +102,6 @@ export const useAppState = create<AppState>((set) => ({
setDockerAvailable: (available) => set({ dockerAvailable: available }),
imageExists: null,
setImageExists: (exists) => set({ imageExists: exists }),
hasKey: null,
setHasKey: (has) => set({ hasKey: has }),
// App settings
appSettings: null,
setAppSettings: (settings) => set({ appSettings: settings }),

View File

@@ -0,0 +1,36 @@
import { describe, it, expect } from "vitest";
import { readFileSync, existsSync } from "fs";
import { resolve } from "path";
describe("Window icon configuration", () => {
const srcTauriDir = resolve(__dirname, "../../src-tauri");
it("lib.rs sets window icon using set_icon in setup hook", () => {
const libRs = readFileSync(resolve(srcTauriDir, "src/lib.rs"), "utf-8");
expect(libRs).toContain("set_icon");
expect(libRs).toContain("icon.png");
});
it("Cargo.toml enables image-png feature for icon loading", () => {
const cargoToml = readFileSync(resolve(srcTauriDir, "Cargo.toml"), "utf-8");
expect(cargoToml).toContain("image-png");
});
it("icon.png exists in the icons directory", () => {
const iconPath = resolve(srcTauriDir, "icons/icon.png");
expect(existsSync(iconPath)).toBe(true);
});
it("icon.ico exists in the icons directory for Windows", () => {
const icoPath = resolve(srcTauriDir, "icons/icon.ico");
expect(existsSync(icoPath)).toBe(true);
});
it("tauri.conf.json includes icon.ico in bundle icons", () => {
const config = JSON.parse(
readFileSync(resolve(srcTauriDir, "tauri.conf.json"), "utf-8")
);
expect(config.bundle.icon).toContain("icons/icon.ico");
expect(config.bundle.icon).toContain("icons/icon.png");
});
});

1
app/src/test/setup.ts Normal file
View File

@@ -0,0 +1 @@
import "@testing-library/jest-dom/vitest";

View File

@@ -17,5 +17,6 @@
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src"]
"include": ["src"],
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/test"]
}

11
app/vitest.config.ts Normal file
View File

@@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: ["./src/test/setup.ts"],
},
});

View File

@@ -1,5 +1,6 @@
FROM ubuntu:24.04
# Multi-arch: builds for linux/amd64 and linux/arm64 (Apple Silicon)
# Avoid interactive prompts during package install
ENV DEBIAN_FRONTEND=noninteractive
@@ -19,6 +20,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
unzip \
pkg-config \
libssl-dev \
cron \
&& rm -rf /var/lib/apt/lists/*
# Remove default ubuntu user to free UID 1000 for host-user remapping
@@ -101,5 +103,9 @@ WORKDIR /workspace
USER root
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
RUN chmod +x /usr/local/bin/triple-c-scheduler
COPY triple-c-task-runner /usr/local/bin/triple-c-task-runner
RUN chmod +x /usr/local/bin/triple-c-task-runner
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

@@ -103,6 +103,27 @@ if [ -n "$CLAUDE_INSTRUCTIONS" ]; then
unset CLAUDE_INSTRUCTIONS
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.
if [ -n "$MCP_SERVERS_JSON" ]; then
CLAUDE_JSON="/home/claude/.claude.json"
if [ -f "$CLAUDE_JSON" ]; then
# Merge: existing config + MCP config (MCP keys override on conflict)
MERGED=$(jq -s '.[0] * .[1]' "$CLAUDE_JSON" <(printf '%s' "$MCP_SERVERS_JSON") 2>/dev/null)
if [ -n "$MERGED" ]; then
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
else
echo "entrypoint: warning — failed to merge MCP config into $CLAUDE_JSON"
fi
else
printf '%s\n' "$MCP_SERVERS_JSON" > "$CLAUDE_JSON"
fi
chown claude:claude "$CLAUDE_JSON"
chmod 600 "$CLAUDE_JSON"
unset MCP_SERVERS_JSON
fi
# ── Docker socket permissions ────────────────────────────────────────────────
if [ -S /var/run/docker.sock ]; then
DOCKER_GID=$(stat -c '%g' /var/run/docker.sock)
@@ -113,6 +134,59 @@ if [ -S /var/run/docker.sock ]; then
usermod -aG "$DOCKER_GROUP" claude
fi
# ── Timezone setup ───────────────────────────────────────────────────────────
if [ -n "${TZ:-}" ]; then
if [ -f "/usr/share/zoneinfo/$TZ" ]; then
ln -sf "/usr/share/zoneinfo/$TZ" /etc/localtime
echo "$TZ" > /etc/timezone
echo "entrypoint: timezone set to $TZ"
else
echo "entrypoint: warning — timezone '$TZ' not found in /usr/share/zoneinfo"
fi
fi
# ── Scheduler setup ─────────────────────────────────────────────────────────
SCHEDULER_DIR="/home/claude/.claude/scheduler"
mkdir -p "$SCHEDULER_DIR/tasks" "$SCHEDULER_DIR/logs" "$SCHEDULER_DIR/notifications"
chown -R claude:claude "$SCHEDULER_DIR"
# Start cron daemon (runs as root, executes jobs per user crontab)
cron
# Save environment variables for cron jobs (cron runs with a minimal env)
ENV_FILE="$SCHEDULER_DIR/.env"
: > "$ENV_FILE"
env | while IFS='=' read -r key value; do
case "$key" in
ANTHROPIC_*|AWS_*|CLAUDE_CODE_*|PATH|HOME|LANG|TZ|COLORTERM)
# Escape single quotes in value and write as KEY='VALUE'
escaped_value=$(printf '%s' "$value" | sed "s/'/'\\\\''/g")
printf "%s='%s'\n" "$key" "$escaped_value" >> "$ENV_FILE"
;;
esac
done
chown claude:claude "$ENV_FILE"
chmod 600 "$ENV_FILE"
# Restore crontab from persisted task JSON files (survives container recreation)
if ls "$SCHEDULER_DIR/tasks/"*.json >/dev/null 2>&1; then
CRON_TMP=$(mktemp)
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$CRON_TMP"
echo "# Do not edit manually; changes will be overwritten." >> "$CRON_TMP"
echo "" >> "$CRON_TMP"
for task_file in "$SCHEDULER_DIR/tasks/"*.json; do
[ -f "$task_file" ] || continue
enabled=$(jq -r '.enabled' "$task_file")
[ "$enabled" = "true" ] || continue
schedule=$(jq -r '.schedule' "$task_file")
id=$(jq -r '.id' "$task_file")
echo "$schedule /usr/local/bin/triple-c-task-runner $id" >> "$CRON_TMP"
done
crontab -u claude "$CRON_TMP" 2>/dev/null || true
rm -f "$CRON_TMP"
echo "entrypoint: restored crontab from persisted tasks"
fi
# ── Stay alive as claude ─────────────────────────────────────────────────────
echo "Triple-C container ready."
exec su -s /bin/bash claude -c "exec sleep infinity"

View File

@@ -0,0 +1,436 @@
#!/bin/bash
# triple-c-scheduler — CLI for managing scheduled tasks in Triple-C containers
# Tasks are stored as JSON files and crontab is rebuilt from them as the source of truth.
set -euo pipefail
SCHEDULER_DIR="${HOME}/.claude/scheduler"
TASKS_DIR="${SCHEDULER_DIR}/tasks"
LOGS_DIR="${SCHEDULER_DIR}/logs"
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
# ── Helpers ──────────────────────────────────────────────────────────────────
ensure_dirs() {
mkdir -p "$TASKS_DIR" "$LOGS_DIR" "$NOTIFICATIONS_DIR"
}
generate_id() {
head -c 4 /dev/urandom | od -An -tx1 | tr -d ' \n'
}
rebuild_crontab() {
local tmp
tmp=$(mktemp)
# Header
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$tmp"
echo "# Do not edit manually; changes will be overwritten." >> "$tmp"
echo "" >> "$tmp"
for task_file in "$TASKS_DIR"/*.json; do
[ -f "$task_file" ] || continue
local enabled schedule id
enabled=$(jq -r '.enabled' "$task_file")
[ "$enabled" = "true" ] || continue
schedule=$(jq -r '.schedule' "$task_file")
id=$(jq -r '.id' "$task_file")
echo "$schedule /usr/local/bin/triple-c-task-runner $id" >> "$tmp"
done
crontab "$tmp" 2>/dev/null || true
rm -f "$tmp"
}
usage() {
cat <<'EOF'
Usage: triple-c-scheduler <command> [options]
Commands:
add Add a new scheduled task
remove Remove a task
enable Enable a disabled task
disable Disable a task
list List all tasks
logs Show execution logs
run Manually trigger a task now
notifications Show or clear completion notifications
Add options:
--name NAME Task name (required)
--prompt "TASK" Task prompt for Claude (required)
--schedule "CRON" Cron schedule expression (for recurring tasks)
--at "DATETIME" Target datetime as "YYYY-MM-DD HH:MM" (for one-time tasks)
--working-dir DIR Working directory (default: /workspace)
Remove/Enable/Disable/Run options:
--id ID Task ID (required)
Logs options:
--id ID Show logs for a specific task (optional)
--tail N Show last N lines (default: 50)
Notifications options:
--clear Clear all notifications
Examples:
triple-c-scheduler add --name "run-tests" --schedule "*/30 * * * *" --prompt "Run the test suite and report results"
triple-c-scheduler add --name "friday-commit" --at "2026-03-06 16:00" --prompt "Commit all changes with a descriptive message"
triple-c-scheduler list
triple-c-scheduler logs --id a1b2c3d4 --tail 20
triple-c-scheduler run --id a1b2c3d4
EOF
}
# ── Commands ─────────────────────────────────────────────────────────────────
cmd_add() {
local name="" prompt="" schedule="" at="" working_dir="/workspace"
while [[ $# -gt 0 ]]; do
case "$1" in
--name) name="$2"; shift 2 ;;
--prompt) prompt="$2"; shift 2 ;;
--schedule) schedule="$2"; shift 2 ;;
--at) at="$2"; shift 2 ;;
--working-dir) working_dir="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -z "$name" ]; then
echo "Error: --name is required" >&2
return 1
fi
if [ -z "$prompt" ]; then
echo "Error: --prompt is required" >&2
return 1
fi
if [ -z "$schedule" ] && [ -z "$at" ]; then
echo "Error: either --schedule or --at is required" >&2
return 1
fi
if [ -n "$schedule" ] && [ -n "$at" ]; then
echo "Error: use either --schedule or --at, not both" >&2
return 1
fi
local id task_type cron_expr
id=$(generate_id)
if [ -n "$at" ]; then
task_type="once"
# Parse "YYYY-MM-DD HH:MM" into cron expression
local year month day hour minute
if ! [[ "$at" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})\ ([0-9]{2}):([0-9]{2})$ ]]; then
echo "Error: --at must be in format 'YYYY-MM-DD HH:MM'" >&2
return 1
fi
year="${BASH_REMATCH[1]}"
month="${BASH_REMATCH[2]}"
day="${BASH_REMATCH[3]}"
hour="${BASH_REMATCH[4]}"
minute="${BASH_REMATCH[5]}"
# Remove leading zeros for cron
month=$((10#$month))
day=$((10#$day))
hour=$((10#$hour))
minute=$((10#$minute))
cron_expr="$minute $hour $day $month *"
else
task_type="recurring"
cron_expr="$schedule"
fi
local created_at
created_at=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
local task_json
task_json=$(jq -n \
--arg id "$id" \
--arg name "$name" \
--arg prompt "$prompt" \
--arg schedule "$cron_expr" \
--arg type "$task_type" \
--arg at "$at" \
--arg created_at "$created_at" \
--argjson enabled true \
--arg working_dir "$working_dir" \
'{
id: $id,
name: $name,
prompt: $prompt,
schedule: $schedule,
type: $type,
at: $at,
created_at: $created_at,
enabled: $enabled,
working_dir: $working_dir
}')
echo "$task_json" > "$TASKS_DIR/${id}.json"
rebuild_crontab
echo "Task created:"
echo " ID: $id"
echo " Name: $name"
echo " Type: $task_type"
if [ "$task_type" = "once" ]; then
echo " At: $at"
fi
echo " Schedule: $cron_expr"
echo " Prompt: $prompt"
}
cmd_remove() {
local id=""
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -z "$id" ]; then
echo "Error: --id is required" >&2
return 1
fi
local task_file="$TASKS_DIR/${id}.json"
if [ ! -f "$task_file" ]; then
echo "Error: task '$id' not found" >&2
return 1
fi
local name
name=$(jq -r '.name' "$task_file")
rm -f "$task_file"
rebuild_crontab
echo "Removed task '$name' ($id)"
}
cmd_enable() {
local id=""
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -z "$id" ]; then
echo "Error: --id is required" >&2
return 1
fi
local task_file="$TASKS_DIR/${id}.json"
if [ ! -f "$task_file" ]; then
echo "Error: task '$id' not found" >&2
return 1
fi
local tmp
tmp=$(mktemp)
jq '.enabled = true' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
rebuild_crontab
local name
name=$(jq -r '.name' "$task_file")
echo "Enabled task '$name' ($id)"
}
cmd_disable() {
local id=""
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -z "$id" ]; then
echo "Error: --id is required" >&2
return 1
fi
local task_file="$TASKS_DIR/${id}.json"
if [ ! -f "$task_file" ]; then
echo "Error: task '$id' not found" >&2
return 1
fi
local tmp
tmp=$(mktemp)
jq '.enabled = false' "$task_file" > "$tmp" && mv "$tmp" "$task_file"
rebuild_crontab
local name
name=$(jq -r '.name' "$task_file")
echo "Disabled task '$name' ($id)"
}
cmd_list() {
local found=false
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "ID" "NAME" "TYPE" "ENABLED" "SCHEDULE" "PROMPT"
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "──────────" "────────────────────" "──────────" "─────────" "────────────────────" "──────────────────────────────"
for task_file in "$TASKS_DIR"/*.json; do
[ -f "$task_file" ] || continue
found=true
local id name type enabled schedule at prompt
id=$(jq -r '.id' "$task_file")
name=$(jq -r '.name' "$task_file")
type=$(jq -r '.type' "$task_file")
enabled=$(jq -r '.enabled' "$task_file")
schedule=$(jq -r '.schedule' "$task_file")
at=$(jq -r '.at // ""' "$task_file")
prompt=$(jq -r '.prompt' "$task_file")
local display_schedule="$schedule"
if [ "$type" = "once" ] && [ -n "$at" ]; then
display_schedule="at $at"
fi
# Truncate long fields for display
[ ${#name} -gt 20 ] && name="${name:0:17}..."
[ ${#display_schedule} -gt 20 ] && display_schedule="${display_schedule:0:17}..."
[ ${#prompt} -gt 30 ] && prompt="${prompt:0:27}..."
printf "%-10s %-20s %-10s %-9s %-20s %s\n" "$id" "$name" "$type" "$enabled" "$display_schedule" "$prompt"
done
if [ "$found" = "false" ]; then
echo "No scheduled tasks."
fi
}
cmd_logs() {
local id="" tail_n=50
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
--tail) tail_n="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -n "$id" ]; then
local log_dir="$LOGS_DIR/$id"
if [ ! -d "$log_dir" ]; then
echo "No logs found for task '$id'"
return 0
fi
# Show the most recent log file
local latest
latest=$(ls -t "$log_dir"/*.log 2>/dev/null | head -1)
if [ -z "$latest" ]; then
echo "No logs found for task '$id'"
return 0
fi
echo "=== Latest log for task $id: $(basename "$latest") ==="
tail -n "$tail_n" "$latest"
else
# Show recent logs across all tasks
local all_logs
all_logs=$(find "$LOGS_DIR" -name "*.log" -type f 2>/dev/null | sort -r | head -n 10)
if [ -z "$all_logs" ]; then
echo "No logs found."
return 0
fi
for log_file in $all_logs; do
local task_id
task_id=$(basename "$(dirname "$log_file")")
echo "=== Task $task_id: $(basename "$log_file") ==="
tail -n 5 "$log_file"
echo ""
done
fi
}
cmd_run() {
local id=""
while [[ $# -gt 0 ]]; do
case "$1" in
--id) id="$2"; shift 2 ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ -z "$id" ]; then
echo "Error: --id is required" >&2
return 1
fi
local task_file="$TASKS_DIR/${id}.json"
if [ ! -f "$task_file" ]; then
echo "Error: task '$id' not found" >&2
return 1
fi
local name
name=$(jq -r '.name' "$task_file")
echo "Manually triggering task '$name' ($id)..."
/usr/local/bin/triple-c-task-runner "$id"
}
cmd_notifications() {
local clear=false
while [[ $# -gt 0 ]]; do
case "$1" in
--clear) clear=true; shift ;;
*) echo "Unknown option: $1" >&2; return 1 ;;
esac
done
if [ "$clear" = "true" ]; then
rm -f "$NOTIFICATIONS_DIR"/*.notify
echo "Notifications cleared."
return 0
fi
local found=false
for notify_file in $(ls -t "$NOTIFICATIONS_DIR"/*.notify 2>/dev/null); do
[ -f "$notify_file" ] || continue
found=true
cat "$notify_file"
echo "---"
done
if [ "$found" = "false" ]; then
echo "No notifications."
fi
}
# ── Main ─────────────────────────────────────────────────────────────────────
ensure_dirs
if [ $# -eq 0 ]; then
usage
exit 1
fi
command="$1"
shift
case "$command" in
add) cmd_add "$@" ;;
remove) cmd_remove "$@" ;;
enable) cmd_enable "$@" ;;
disable) cmd_disable "$@" ;;
list) cmd_list ;;
logs) cmd_logs "$@" ;;
run) cmd_run "$@" ;;
notifications) cmd_notifications "$@" ;;
help|--help|-h) usage ;;
*)
echo "Unknown command: $command" >&2
usage
exit 1
;;
esac

View File

@@ -0,0 +1,142 @@
#!/bin/bash
# triple-c-task-runner — Executes a scheduled task via Claude Code agent
# Called by cron with a task ID argument. Handles locking, logging,
# notifications, one-time task cleanup, and log pruning.
set -uo pipefail
SCHEDULER_DIR="${HOME}/.claude/scheduler"
TASKS_DIR="${SCHEDULER_DIR}/tasks"
LOGS_DIR="${SCHEDULER_DIR}/logs"
NOTIFICATIONS_DIR="${SCHEDULER_DIR}/notifications"
ENV_FILE="${SCHEDULER_DIR}/.env"
TASK_ID="${1:-}"
if [ -z "$TASK_ID" ]; then
echo "Usage: triple-c-task-runner <task-id>" >&2
exit 1
fi
TASK_FILE="${TASKS_DIR}/${TASK_ID}.json"
LOCK_FILE="${SCHEDULER_DIR}/.lock-${TASK_ID}"
if [ ! -f "$TASK_FILE" ]; then
echo "Task file not found: $TASK_FILE" >&2
exit 1
fi
# ── Acquire lock (prevent overlapping runs of the same task) ─────────────────
exec 200>"$LOCK_FILE"
if ! flock -n 200; then
echo "Task $TASK_ID is already running, skipping." >&2
exit 0
fi
# ── Source saved environment ─────────────────────────────────────────────────
if [ -f "$ENV_FILE" ]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
# ── Read task definition ────────────────────────────────────────────────────
PROMPT=$(jq -r '.prompt' "$TASK_FILE")
WORKING_DIR=$(jq -r '.working_dir // "/workspace"' "$TASK_FILE")
TASK_NAME=$(jq -r '.name' "$TASK_FILE")
TASK_TYPE=$(jq -r '.type' "$TASK_FILE")
# ── Prepare log directory ───────────────────────────────────────────────────
TASK_LOG_DIR="${LOGS_DIR}/${TASK_ID}"
mkdir -p "$TASK_LOG_DIR"
TIMESTAMP=$(date +"%Y%m%d-%H%M%S")
LOG_FILE="${TASK_LOG_DIR}/${TIMESTAMP}.log"
# ── Execute Claude agent ────────────────────────────────────────────────────
{
echo "=== Task: $TASK_NAME ($TASK_ID) ==="
echo "=== Started: $(date) ==="
echo "=== Working dir: $WORKING_DIR ==="
echo "=== Prompt: $PROMPT ==="
echo ""
} > "$LOG_FILE"
EXIT_CODE=0
if [ -d "$WORKING_DIR" ]; then
cd "$WORKING_DIR"
claude -p "$PROMPT" --dangerously-skip-permissions >> "$LOG_FILE" 2>&1 || EXIT_CODE=$?
else
echo "Error: working directory '$WORKING_DIR' does not exist" >> "$LOG_FILE"
EXIT_CODE=1
fi
{
echo ""
echo "=== Finished: $(date) ==="
echo "=== Exit code: $EXIT_CODE ==="
} >> "$LOG_FILE"
# ── Write notification ──────────────────────────────────────────────────────
mkdir -p "$NOTIFICATIONS_DIR"
NOTIFY_FILE="${NOTIFICATIONS_DIR}/${TASK_ID}_${TIMESTAMP}.notify"
if [ $EXIT_CODE -eq 0 ]; then
STATUS="SUCCESS"
else
STATUS="FAILED (exit code $EXIT_CODE)"
fi
# Extract a summary (last 10 meaningful lines before the footer)
SUMMARY=$(grep -v "^===" "$LOG_FILE" | grep -v "^$" | tail -n 10)
cat > "$NOTIFY_FILE" <<NOTIFY
Task: $TASK_NAME ($TASK_ID)
Status: $STATUS
Time: $(date)
Type: $TASK_TYPE
Summary:
$SUMMARY
NOTIFY
# ── One-time task cleanup ───────────────────────────────────────────────────
if [ "$TASK_TYPE" = "once" ]; then
rm -f "$TASK_FILE"
# Rebuild crontab to remove the completed one-time task
/usr/local/bin/triple-c-scheduler list > /dev/null 2>&1 || true
# Direct crontab rebuild (in case scheduler list doesn't trigger it)
TMP_CRON=$(mktemp)
echo "# Triple-C scheduled tasks — managed by triple-c-scheduler" > "$TMP_CRON"
echo "# Do not edit manually; changes will be overwritten." >> "$TMP_CRON"
echo "" >> "$TMP_CRON"
for tf in "$TASKS_DIR"/*.json; do
[ -f "$tf" ] || continue
local_enabled=$(jq -r '.enabled' "$tf")
[ "$local_enabled" = "true" ] || continue
local_schedule=$(jq -r '.schedule' "$tf")
local_id=$(jq -r '.id' "$tf")
echo "$local_schedule /usr/local/bin/triple-c-task-runner $local_id" >> "$TMP_CRON"
done
crontab "$TMP_CRON" 2>/dev/null || true
rm -f "$TMP_CRON"
fi
# ── Prune old logs (keep 20 per task) ───────────────────────────────────────
LOG_COUNT=$(find "$TASK_LOG_DIR" -name "*.log" -type f 2>/dev/null | wc -l)
if [ "$LOG_COUNT" -gt 20 ]; then
find "$TASK_LOG_DIR" -name "*.log" -type f | sort | head -n $((LOG_COUNT - 20)) | xargs rm -f
fi
# ── Prune old notifications (keep 50 total) ─────────────────────────────────
NOTIFY_COUNT=$(find "$NOTIFICATIONS_DIR" -name "*.notify" -type f 2>/dev/null | wc -l)
if [ "$NOTIFY_COUNT" -gt 50 ]; then
find "$NOTIFICATIONS_DIR" -name "*.notify" -type f | sort | head -n $((NOTIFY_COUNT - 50)) | xargs rm -f
fi
# Release lock
flock -u 200
rm -f "$LOCK_FILE"
exit $EXIT_CODE

BIN
triple-c-app-logov2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB