diff --git a/.gitignore b/.gitignore index 411bf48..2451dbf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,9 @@ # Per-user container name/settings — run `./claudaris config` to generate. /.env +# Generated by `claudaris start` when $DATA_DIR/ssh exists, to opt the +# compose service into the read-only SSH mount. Absent otherwise. +/docker-compose.override.yml + # Claude Code's auto-generated per-user permission cache. /.claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md index bbd7fec..1e55637 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,17 @@ Docker scaffold for running Claude Code in an isolated container (see README.md) (image/container name — the default `claudaris` is meant to be overridden per person, e.g. `chris-claude`, so multiple users can each run their own container off this same repo), `DATA_DIR`, and `WORKSPACE_DIR`. `claudaris` - sources it if present. + sources it if present; `docker-compose.yml` reads the same vars, and + `docker compose` picks up `.env` on its own since it lives in the project + directory compose runs from — so `claudaris` and plain `docker compose` + commands agree without any extra wiring. +- `docker-compose.yml` — the actual container definition (build context, + image/container/hostname name, volumes); `claudaris` shells out to + `docker compose` rather than raw `docker build`/`run`/`exec`. The one + mount it can't express is the opt-in SSH mount (see `claudaris start` + below), which needs a host path to exist first — that's added via a + gitignored `docker-compose.override.yml` that `claudaris start` writes or + removes as needed; compose merges it automatically when present. - `Dockerfile` — archlinux base image with bash, git, nodejs/npm, tmux, vim, fastfetch, etc. `files/bash_aliases` is baked in at `/opt/dotfiles/bash_aliases` and `files/bashrc` becomes the image's default `/root/.bashrc` — both are @@ -57,33 +67,39 @@ Docker scaffold for running Claude Code in an isolated container (see README.md) - `config` (alias `configure`) — interactive wizard prompting for `NAME`, `DATA_DIR`, `WORKSPACE_DIR` (showing current/default values, enter to keep) and writing them to `.env`. Re-run any time to update it. - - `build` — builds the image, tagged `$NAME` (see `.env` above). - - `start` — runs the container as `$NAME` with `--hostname "$NAME"` (so the - shell prompt reads `root@$NAME`, e.g. `root@claudaris`, instead of a - random container ID). `/root/.bashrc`, `/opt/dotfiles/bash_aliases`, and - the two auth files (`~/.claude/.credentials.json`, `~/.claude.json`) are - each bind-mounted individually — seeded once (empty, for the auth files; - from `files/bashrc`/`files/bash_aliases`, for the dotfiles) into - `$DATA_DIR/home/{.bashrc,bash_aliases}` and - `$DATA_DIR/claude/{credentials,claude}.json` on the host, so they can be - edited/persist without a rebuild — including a coworker dropping in their - own aliases. Individual-file mounts matter for the auth files - specifically: Claude Code likely saves them atomically (write a temp - file, then `rename()` over the target), and `rename()` onto a symlink - replaces the symlink instead of writing through it — silently breaking - persistence after the first write. A bind mount doesn't have that - failure mode, which is why these aren't just symlinked from a directory - volume the way an earlier version of this setup did it. `$WORKSPACE_DIR` - is mounted at `/projects`. Also offers an opt-in `/root/.ssh` mount - (read-only) for reaching other nodes: create `$DATA_DIR/ssh` on the host - and populate it before starting the container to enable it. `DATA_DIR` - defaults to `/data/$NAME`; `WORKSPACE_DIR` defaults to - `/home/$USER/projects` (override via `claudaris config`). - - `connect` — `docker start`s `$NAME` (a no-op if it's already running) - then attaches to its tmux session, so it also works right after the + - `build` — `docker compose build --no-cache --pull` (see `.env` above for + `$NAME`). + - `start` — `docker compose up -d`, so the shell prompt reads + `root@$NAME`, e.g. `root@claudaris`, instead of a random container ID + (`hostname:` in `docker-compose.yml`). `/root/.bashrc`, + `/opt/dotfiles/bash_aliases`, and the auth files + (`~/.claude/.credentials.json`, `~/.claude.json`, + `~/.local/share/opencode/auth.json`) are each bind-mounted individually + in `docker-compose.yml` — seeded once by `start` (empty, for the auth + files; from `files/bashrc`/`files/bash_aliases`, for the dotfiles) into + `$DATA_DIR/home/{.bashrc,bash_aliases}`, + `$DATA_DIR/claude/{credentials,claude}.json`, and + `$DATA_DIR/opencode/auth.json` on the host, so they can be edited/persist + without a rebuild — including a coworker dropping in their own aliases. + Individual-file mounts matter for the auth files specifically: Claude + Code and OpenCode likely save them atomically (write a temp file, then + `rename()` over the target), and `rename()` onto a symlink replaces the + symlink instead of writing through it — silently breaking persistence + after the first write. A bind mount doesn't have that failure mode, + which is why these aren't just symlinked from a directory volume the way + an earlier version of this setup did it. `$WORKSPACE_DIR` is mounted at + `/projects`. Also offers an opt-in `/root/.ssh` mount (read-only) for + reaching other nodes: create `$DATA_DIR/ssh` on the host and populate it + before starting the container to enable it — `start` writes or removes a + gitignored `docker-compose.override.yml` for this mount, since compose + can't make a volume conditional on a host path existing the way the + script itself can. `DATA_DIR` defaults to `/data/$NAME`; `WORKSPACE_DIR` + defaults to `/home/$USER/projects` (override via `claudaris config`). + - `connect` — `docker compose up -d` (a no-op if already running) then + `docker compose exec claudaris bash`, so it also works right after the container has auto-exited (see `Dockerfile`/entrypoint above). - - `remove` (aliases `stop`, `rm`) — stops and removes the container so - a subsequent `start` recreates it fresh. + - `remove` (aliases `stop`, `rm`) — `docker compose down`, so a subsequent + `start` recreates the container fresh. - `help` — usage. - `files/` — plain (non-dot) source files `COPY`'d into the image at build time: `bashrc`/`bash_aliases` (dotfiles — see `Dockerfile` above), @@ -102,22 +118,19 @@ Docker scaffold for running Claude Code in an isolated container (see README.md) ## Common commands ```bash # build the image -docker build -t "$NAME" . +docker compose build --no-cache --pull # start the container -docker run -d \ - --name "$NAME" \ - --hostname "$NAME" \ - -v "$DATA_DIR/home/.bashrc:/root/.bashrc" \ - -v "$DATA_DIR/home/bash_aliases:/opt/dotfiles/bash_aliases" \ - -v "$DATA_DIR/claude/credentials.json:/root/.claude/.credentials.json" \ - -v "$DATA_DIR/claude/claude.json:/root/.claude.json" \ - -v "$WORKSPACE_DIR:/projects" \ - "$NAME" +docker compose up -d -# attach to the running container's tmux session -docker exec -it "$NAME" tmux +# attach to the running container +docker compose exec claudaris bash ``` +`docker compose` reads `NAME`/`DATA_DIR`/`WORKSPACE_DIR` from `.env` +automatically (no `claudaris` involvement needed), but the host-side seeding +`claudaris start` does (dotfiles, auth files, the SSH override) isn't +replicated by compose itself — run `claudaris start` at least once per host +before using raw compose commands. ## Notes No test suite or CI — this is infra/config, not application code. Verify changes diff --git a/Dockerfile b/Dockerfile index 282488d..a6a514c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,6 +15,7 @@ RUN pacman -Sy --noconfirm archlinux-keyring && \ mariadb-clients \ nodejs \ npm \ + opencode \ openssh \ php \ php-sqlite \ @@ -78,8 +79,19 @@ RUN tag="$(curl -fsSL https://gitea.com/api/v1/repos/gitea/gitea-mcp/releases/la # hardcodes the InvokeAI URL, so patch it to honor $INVOKEAI_BASE_URL; the # grep fails the build if upstream changes shape and the patch stops landing. RUN uv tool install invokeai-mcp-server && \ - sed -i 's|^INVOKEAI_BASE_URL = .*|import os\nINVOKEAI_BASE_URL = os.environ.get("INVOKEAI_BASE_URL", "http://127.0.0.1:9090")|' \ - /root/.local/share/uv/tools/invokeai-mcp-server/lib/python*/site-packages/invokeai_mcp_server.py && \ + python -c " +import glob +path = glob.glob('/root/.local/share/uv/tools/invokeai-mcp-server/lib/python*/site-packages/invokeai_mcp_server.py')[0] +with open(path) as f: + c = f.read() +c = 'import os\n' + c +c = c.replace( + 'INVOKEAI_BASE_URL = \"http://127.0.0.1:9090\"', + 'INVOKEAI_BASE_URL = os.environ.get(\"INVOKEAI_BASE_URL\", \"http://127.0.0.1:9090\")' +) +with open(path, 'w') as f: + f.write(c) +" && \ grep -q 'INVOKEAI_BASE_URL = os.environ.get' \ /root/.local/share/uv/tools/invokeai-mcp-server/lib/python*/site-packages/invokeai_mcp_server.py diff --git a/README.md b/README.md index f2a93fa..0ea761f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,11 @@ # claudaris -Claudaris (klaw-DAR-iss): A moveable docker container for Claude code. using Arch btw. +Claudaris (klaw-DAR-iss): A moveable docker container for Claude and OpenCode. using Arch btw. + +`./claudaris` is a thin wrapper around `docker compose` (see +`docker-compose.yml`) — `build`/`start`/`connect`/`remove` still work exactly +as below, and `docker compose` commands work directly too if you'd rather use +those. Full documentation lives in [`docs/`](docs/README.md): [getting started](docs/getting-started.md), the [`claudaris` executable](docs/claudaris.md), and [MCP servers](docs/mcp.md). @@ -25,6 +30,8 @@ defaults to `/data/$NAME`; `WORKSPACE_DIR` (the host dir mounted as As root run build (always `--no-cache --pull`, so it picks up the latest Claude Code release): ``` sudo ./claudaris build +# or +docker buildx build --no-cache -t 4lights/claudaris:latest . ``` ## Start the container diff --git a/claudaris b/claudaris index e1d2238..865fbdc 100755 --- a/claudaris +++ b/claudaris @@ -8,8 +8,17 @@ REPO_DIR="$(pwd)" # people can each run their own container from this repo) and other options. # It writes them to .env, which is gitignored and sourced here if present. NAME=claudaris +CLAUDARIS_IMAGE=4lights/claudaris:latest [ -f .env ] && set -a && . .env && set +a +# docker-compose.yml reads NAME/DATA_DIR/WORKSPACE_DIR for variable +# substitution, so every subcommand that shells out to `docker compose` +# needs them exported, with the same defaults `cmd_start` seeds on disk. +export NAME +export CLAUDARIS_IMAGE +export DATA_DIR="${DATA_DIR:-/data/$NAME}" +export WORKSPACE_DIR="${WORKSPACE_DIR:-/home/${USER:-$(id -un)}/projects}" + usage() { cat < @@ -39,9 +48,14 @@ cmd_config() { read -r -p "WORKSPACE_DIR [$workspace_dir_default]: " input WORKSPACE_DIR="${input:-$workspace_dir_default}" + local claudaris_image_default="${CLAUDARIS_IMAGE:-4lights/claudaris:latest}" + read -r -p "CLAUDARIS_IMAGE [$claudaris_image_default]: " input + CLAUDARIS_IMAGE="${input:-$claudaris_image_default}" + cat > .env < "$REPO_DIR/docker-compose.override.yml" </dev/null - docker exec -it "$NAME" bash + docker compose up -d >/dev/null + docker compose exec claudaris bash } cmd_remove() { - docker stop "$NAME" >/dev/null 2>&1 || true - docker rm "$NAME" + docker compose down } case "${1:-help}" in diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8a1b788 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,14 @@ +services: + claudaris: + image: ${CLAUDARIS_IMAGE:-4lights/claudaris:latest} + container_name: ${NAME:-claudaris} + hostname: ${NAME:-claudaris} + stdin_open: true + tty: true + volumes: + - ${DATA_DIR:-/data/claudaris}/home/.bashrc:/root/.bashrc + - ${DATA_DIR:-/data/claudaris}/home/bash_aliases:/opt/dotfiles/bash_aliases + - ${DATA_DIR:-/data/claudaris}/claude/credentials.json:/root/.claude/.credentials.json + - ${DATA_DIR:-/data/claudaris}/claude/claude.json:/root/.claude.json + - ${DATA_DIR:-/data/claudaris}/opencode/auth.json:/root/.local/share/opencode/auth.json + - ${WORKSPACE_DIR:-./}:/projects diff --git a/docs/claudaris.md b/docs/claudaris.md index 2ee115e..d528d32 100644 --- a/docs/claudaris.md +++ b/docs/claudaris.md @@ -1,8 +1,11 @@ # The `claudaris` executable -`./claudaris` is a single bash script at the repo root that wraps every -Docker operation. It `cd`s to its own directory first, so it can be invoked -from anywhere. Run it with no arguments (or `help`) for usage. +`./claudaris` is a single bash script at the repo root that wraps +`docker compose` (see `docker-compose.yml`). It `cd`s to its own directory +first, so it can be invoked from anywhere. Run it with no arguments (or +`help`) for usage. Plain `docker compose` subcommands work directly too, as +long as `.env` is present (compose reads it automatically) — `claudaris` +just adds the `config` wizard and the host-side seeding/mount logic below. ``` Usage: ./claudaris @@ -33,43 +36,53 @@ means. sudo ./claudaris build ``` -Runs `docker build --no-cache --pull -t "$NAME" .`. Always uncached so a +Runs `docker compose build --no-cache --pull`. Always uncached so a rebuild picks up the latest Arch packages, the latest Claude Code release (installed straight into `/root` in the image), and the latest [MCP server](mcp.md) releases. ## `start` -Creates and starts the container — or just `docker start`s it if a container -named `$NAME` already exists (in that case none of the mount setup below is -re-evaluated; use `remove` first to pick up mount changes). +Runs `docker compose up -d`, which creates the container if it doesn't exist +and (re)starts it otherwise, per `docker-compose.yml`. Container/image name, +volumes, and workspace mount come from `NAME`/`DATA_DIR`/`WORKSPACE_DIR`, +which compose reads from `.env` the same way `claudaris` does — mount +changes in `docker-compose.yml` take effect on the next `up -d` without +needing `remove` first. -On the host side it first seeds, without overwriting anything that already -exists: +Before calling compose, `start` seeds the host side, without overwriting +anything that already exists: - `$DATA_DIR/home/.bashrc` and `$DATA_DIR/home/bash_aliases` — copied from `files/bashrc` and `files/bash_aliases`. -- `$DATA_DIR/claude/credentials.json` and `$DATA_DIR/claude/claude.json` — - created empty. Seeding them as files matters: if Docker had to create the - mount targets itself it would make directories, breaking the login mounts. +- `$DATA_DIR/claude/credentials.json`, `$DATA_DIR/claude/claude.json`, and + `$DATA_DIR/opencode/auth.json` — created empty. Seeding them as files + matters: if Docker had to create the mount targets itself it would make + directories, breaking the login mounts. +- `docker-compose.override.yml` (gitignored) — written to add the SSH mount + below if `$DATA_DIR/ssh` exists, removed otherwise. Compose merges this + file automatically when present; it's the only mount that can't live in + `docker-compose.yml` directly, since compose has no way to make a volume + conditional on a host path existing. -Then it runs the container with: +The mounts, all declared in `docker-compose.yml`: -| Mount / flag | Purpose | +| Mount | Purpose | | --- | --- | -| `--name "$NAME" --hostname "$NAME"` | Prompt reads `root@$NAME` instead of a random container ID. | +| `hostname: ${NAME}` | Prompt reads `root@$NAME` instead of a random container ID. | | `$DATA_DIR/home/.bashrc` → `/root/.bashrc` | Per-host shell config, editable without a rebuild. | | `$DATA_DIR/home/bash_aliases` → `/opt/dotfiles/bash_aliases` | Per-host aliases and [MCP env vars](mcp.md); sourced by `.bashrc`. | | `$DATA_DIR/claude/credentials.json` → `/root/.claude/.credentials.json` | Claude Code OAuth token. | | `$DATA_DIR/claude/claude.json` → `/root/.claude.json` | Claude Code account/onboarding state and user-scope MCP config. | +| `$DATA_DIR/opencode/auth.json` → `/root/.local/share/opencode/auth.json` | OpenCode provider credentials (API keys/OAuth tokens). | | `$WORKSPACE_DIR` → `/projects` | Your project workspace. | -| `$DATA_DIR/ssh` → `/root/.ssh` (read-only, only if the host dir exists) | Opt-in SSH access to other machines. | +| `$DATA_DIR/ssh` → `/root/.ssh` (read-only, override file, only if the host dir exists) | Opt-in SSH access to other machines. | The login files are individual file mounts rather than a directory volume on -purpose: Claude Code saves them atomically (write a temp file, then -`rename()` over the target), and `rename()` onto a symlink replaces the -symlink instead of writing through it — which would silently break -persistence. A bind mount doesn't have that failure mode. +purpose: both Claude Code and OpenCode likely save them atomically (write a +temp file, then `rename()` over the target), and `rename()` onto a symlink +replaces the symlink instead of writing through it — which would silently +break persistence. A bind mount doesn't have that failure mode. ## `connect` @@ -77,20 +90,21 @@ persistence. A bind mount doesn't have that failure mode. sudo ./claudaris connect ``` -Runs `docker start "$NAME"` (a no-op if already running) and then -`docker exec -it "$NAME" tmux`. The in-container `tmux` is a wrapper that -attaches to the `main` session if it exists and creates it otherwise, so -`connect` always lands you in the same session. +Runs `docker compose up -d` (a no-op if already running) and then +`docker compose exec claudaris bash`. -The explicit `docker start` matters because the container stops itself: the +The explicit `up -d` matters because the container stops itself: the entrypoint creates the `main` tmux session and exits once that session ends (tmux defaults — a window closes when its shell exits, the session closes with its last window). There's no `--restart` policy, so after a Ctrl+D the -container sits stopped until `connect` (or `start`) brings it back. +container sits stopped until `connect` (or `start`) brings it back. `tmux` +itself stays installed and available — run it manually inside the container +(the in-container `tmux` wrapper attaches to the `main` session if it exists +and creates it otherwise) if you want it. ## `remove` (aliases: `stop`, `rm`) -Stops (ignoring errors if already stopped) and removes the container, so the -next `start` recreates it fresh — needed after a `build` to actually run the -new image, or after changing mounts. Nothing under `$DATA_DIR` or +Runs `docker compose down`, stopping and removing the container so the next +`start` recreates it fresh — needed after a `build` to actually run the new +image, or after changing mounts. Nothing under `$DATA_DIR` or `$WORKSPACE_DIR` is touched. diff --git a/files/bash_aliases b/files/bash_aliases index ad0adc0..9cde63c 100644 --- a/files/bash_aliases +++ b/files/bash_aliases @@ -4,7 +4,6 @@ # ls long hidden and human readable alias ll='ls -lah' alias l1='ls -1' -alias ls='ls --color=auto' # up levels (up #_of_dir_up) function cd_up() { diff --git a/index.html b/index.html index 715c178..830dd76 100644 --- a/index.html +++ b/index.html @@ -387,10 +387,11 @@

CLAUDARIS

/klaw-DAR-iss/ — a moveable docker container for - Claude Code. using Arch btw. + Claude Code and OpenCode. using Arch btw.
ARCHLINUX + DOCKER COMPOSE TMUX SELF-HOSTED NO-RESTART-POLICY @@ -414,11 +415,14 @@
./claudaris config

It writes your answers to .env (gitignored — every user - keeps their own), which every other command sources. Set - NAME to something unique to you (e.g. claude-ten) - if more than one person is running a container from this same repo - checkout. Re-run config / configure any time - to update it. + keeps their own). claudaris sources it, and + docker compose reads the same file automatically since it + lives in the project directory compose runs from — so + claudaris and plain docker compose commands + always agree. Set NAME to something unique to you (e.g. + claude-ten) if more than one person is running a container + from this same repo checkout. Re-run config / + configure any time to update it.

@@ -429,7 +433,7 @@ - + @@ -442,7 +446,14 @@

Commands

-

Everything runs through the single claudaris entry point.

+

+ claudaris is a thin wrapper around + docker compose (see docker-compose.yml) — it + adds the config wizard and host-side seeding, then shells out to + compose for the actual container lifecycle. Plain docker + compose commands work directly too, once start has + seeded the host once. +

@@ -452,22 +463,22 @@
build
-
Builds the image (always --no-cache --pull, so it picks up the latest Claude Code release).
+
docker compose build --no-cache --pull — always uncached, so it picks up the latest Claude Code / OpenCode releases.
sudo ./claudaris build
start
-
Creates or restarts the container with all volumes wired up.
+
Seeds host files, then docker compose up -d with all volumes wired up.
sudo ./claudaris start
connect
-
Starts the container if needed, then attaches your tmux session.
+
Starts the container if needed, then docker compose execs a shell.
sudo ./claudaris connect
removestop · rm
-
Stops and removes the container so the next start recreates it fresh.
+
docker compose down — stops and removes the container so the next start recreates it fresh.
sudo ./claudaris remove
@@ -481,21 +492,28 @@ before running ./claudaris start. If present, it's bind-mounted read-only to /root/.ssh.

-
off by default — nothing is mounted unless the directory exists.
+
off by default — nothing is mounted unless the directory exists. Since compose can't make a volume conditional on a host path existing, start writes (or removes) a gitignored docker-compose.override.yml to add this mount, which compose merges automatically when present.

Internals

Persistent login. - Claude Code is installed at build time under /root, so a - rebuild always picks up the latest release. Two files are bind-mounted - individually to survive that: ~/.claude/.credentials.json - (the OAuth token) and ~/.claude.json (account/onboarding - state — Claude Code checks this too, so persisting the token alone - isn't enough to avoid a re-login prompt after a rebuild). - ./claudaris start seeds both from empty on the host the - first time it runs. + Claude Code and OpenCode are both installed at build time (Claude Code + via installer straight into /root; OpenCode via + pacman), so a rebuild always picks up the latest release. + Three files are bind-mounted individually to survive that: + ~/.claude/.credentials.json (the OAuth token), + ~/.claude.json (account/onboarding state — Claude Code + checks this too, so persisting the token alone isn't enough to avoid a + re-login prompt after a rebuild), and + ~/.local/share/opencode/auth.json (OpenCode's provider + credentials). ./claudaris start seeds all three from empty + on the host the first time it runs. All three are individual-file + mounts rather than a directory volume, since both tools likely save + atomically (write a temp file, then rename() over the + target) — rename() onto a symlink replaces the symlink + instead of writing through it, silently breaking persistence.

Self-stopping. @@ -518,8 +536,8 @@

Hostname. - The container is started with --hostname "$NAME", so the - prompt reads root@claudaris (or whatever you set + docker-compose.yml sets hostname: ${NAME}, so + the prompt reads root@claudaris (or whatever you set NAME to) instead of a random container ID.

@@ -528,7 +546,9 @@

Repo layout

  • claudaris — the entry point: build / start / connect / remove / help.
  • -
  • Dockerfile — Arch base image, dotfiles, Claude Code install, entrypoint.
  • +
  • docker-compose.yml — the container definition (image, volumes) that claudaris wraps.
  • +
  • docker-compose.override.yml — gitignored, written by start for the opt-in SSH mount.
  • +
  • Dockerfile — Arch base image, dotfiles, Claude Code / OpenCode install, entrypoint.
  • .env — written by claudaris config, per-user, gitignored.
  • files/bashrc, bash_aliases, entrypoint.sh, tmux wrapper, tmux.conf.
  • AGENTS.md — deep-dive notes for anyone (human or agent) hacking on this repo.
  • @@ -536,7 +556,7 @@
    - claudaris moveable · self-hosted · always running the latest Claude Code + claudaris moveable · self-hosted · always running the latest Claude Code + OpenCode
variablepurposedefault
DATA_DIRHost directory for volume mount data — bashrc, aliases, Claude auth, ssh keys.Host directory for volume mount data — bashrc, aliases, Claude Code / OpenCode auth, ssh keys. /data/$NAME