diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 8d9fb5e..b410c74 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,6 +1,6 @@ -FROM mcr.microsoft.com/devcontainers/base:jammy +FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 -ARG PIXI_VERSION=v0.32.1 +ARG PIXI_VERSION=v0.75.0 RUN curl -L -o /usr/local/bin/pixi -fsSL --compressed "https://github.com/prefix-dev/pixi/releases/download/${PIXI_VERSION}/pixi-$(uname -m)-unknown-linux-musl" \ && chmod +x /usr/local/bin/pixi \ @@ -10,4 +10,10 @@ RUN curl -L -o /usr/local/bin/pixi -fsSL --compressed "https://github.com/prefix USER vscode WORKDIR /home/vscode -RUN echo 'eval "$(pixi completion -s bash)"' >> /home/vscode/.bashrc +RUN echo 'eval "$(pixi completion -s bash)"' >> /home/vscode/.bashrc \ + && echo 'export PATH="$HOME/.pixi/bin:$PATH"' >> /home/vscode/.profile \ + && echo '# Workaround: pixi trampoline fails for bash scripts, so add env bin directly' >> /home/vscode/.profile \ + && echo '[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"' >> /home/vscode/.profile + +# Create .config/gh so the host's GitHub CLI config mounts cleanly onto it +RUN mkdir -p /home/vscode/.config/gh diff --git a/.devcontainer/ci/devcontainer.json b/.devcontainer/ci/devcontainer.json new file mode 100644 index 0000000..884dff1 --- /dev/null +++ b/.devcontainer/ci/devcontainer.json @@ -0,0 +1,12 @@ +{ + "name": "python_template-build", + "build": { + "dockerfile": "../Dockerfile", + "context": "." + }, + "features": { + "../claude-code": {}, + // "ghcr.io/devcontainers/features/docker-in-docker:2": {}, + // "ghcr.io/devcontainers/features/common-utils:2": {} + } +} diff --git a/.devcontainer/claude-code/README.md b/.devcontainer/claude-code/README.md new file mode 100644 index 0000000..4e358b1 --- /dev/null +++ b/.devcontainer/claude-code/README.md @@ -0,0 +1,441 @@ +# Claude Code CLI - Local Dev Container Feature + +A local Dev Container Feature that installs the Claude Code CLI and bind-mounts your host machine's Claude configuration directory into the container. + +## What This Feature Does + +This feature combines two capabilities: + +1. **CLI Installation**: `install.sh` runs `pixi global install --channel https://prefix.dev/blooop claude-shim`, and downloads pixi to `/usr/local/bin/pixi` first if the base image does not already carry it +2. **Configuration Mounting**: Bind-mounts your host machine's `~/.claude` directory into the container, read-write + +## What Gets Installed + +- **Claude Code CLI**: The `claude` command becomes available in your container. It comes from the `claude-shim` package on the `blooop` prefix.dev channel, so that channel is a dependency of this feature. `install.sh` checks only that the pixi trampoline exists -- the binary it points at is downloaded on the first `claude` run. +- **VS Code Extension**: Automatically installs the `anthropic.claude-code` extension +- **Configuration Directories**: `install.sh` creates the `.claude/` tree, though it does so while the image is built -- at runtime the host's bind mount covers it + +## What Gets Mounted + +One mount, and it is the whole directory: + +``` +source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind +``` + +There is no `ro` flag and no per-file mount. The host and every container of every branch share one `~/.claude`, read-write, so anything running in a container can modify any of it -- `CLAUDE.md`, `settings.json`, `agents/`, `commands/` and `hooks/` included. + +### What That Means + +`hooks/` and `settings.json` are executed by Claude Code wherever it runs. Content a container writes there therefore runs on the **host**, the next time Claude Code starts on the host, and a `postCreateCommand` from a repository you have not read is enough to put it there. + +It is not a confidentiality boundary either: code running in the container holds the live Claude credentials the mount carries, and under `dl` a `GH_TOKEN` with repo and workflow scopes. + +### Why It Is Still One Read-Write Directory + +Sharing the directory is what makes credentials work across the host and every branch container. `.credentials.json` has to be writable because the access token is short-lived and a refresh has to persist -- a read-only or copied arrangement drifts into a re-auth. `.claude.json` has to be writable because Claude tracks per-workspace onboarding and trust state there, so a container that cannot write it re-onboards on every launch. + +Splitting the rest of the directory into separate read-only binds is possible and is not what this feature does today. What the container buys as it stands is reproducible dependencies and non-colliding concurrent work, not safety against hostile code. `blooop/wayfinder`'s `.devcontainer/devcontainer.json` states the same threat model in the comment above its `mounts` block. + +## Usage + +### Setup + +The feature is declared where the image is built, in `.devcontainer/ci/devcontainer.json`: + +```json +{ + "build": { + "dockerfile": "../Dockerfile", + "context": "." + }, + "features": { + "../claude-code": {} + } +} +``` + +CI publishes that image, and the `devcontainer.json` every branch launches from pulls it and declares neither `features` nor `runArgs`: + +```json +{ + "image": "ghcr.io/blooop/python_template/devcontainer:latest", + "containerEnv": { + "CLAUDE_CONFIG_DIR": "/home/vscode/.claude" + }, + "mounts": [ + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind" + ] +} +``` + +Declaring the feature a second time there makes the devcontainer spec build a derived image on the first launch of every branch, reinstalling what the pulled image already carries. + +### Authentication Uses Host Credentials, Not Host Networking + +No OAuth flow runs inside the container. You authenticate `claude` once on the host, and the `~/.claude` bind mount plus `CLAUDE_CONFIG_DIR=/home/vscode/.claude` point the container at those same credentials, refresh tokens included. Every container of every branch reads them, and nothing has to reach the host's network to do it. + +### Why You Might Opt Into `--network=host` + +The one thing the bind mount does not give you is the interactive OAuth login *from inside* the container, which needs host networking to complete: + +1. You run `claude` → it starts a callback server on a random port in the container +2. Your browser opens the authorize page, you click "Authorize", and it redirects to `http://localhost:/callback` +3. On the default bridge network that port belongs to the container, not the host, so the browser cannot reach it and the CLI sits at "Paste code here" + +With `--network=host` the container shares the host's network namespace, port X in the container *is* port X on the host, and the callback lands. + +Two costs come with it, and they are why this template does not set it: + +- **VS Code extensions stop installing**: [vscode-remote-release#9212](https://github.com/microsoft/vscode-remote-release/issues/9212), covered again under Troubleshooting below. +- **Every port the container binds becomes a host port.** A container per branch is the reason these repos are launched with `dl`, and two branch containers on the host's network namespace collide on the first port they share. + +Host networking also gives the container full access to the host's network, so only use it in environments you trust. + +### Build the Container + +With DevPod: +```bash +devpod up . --recreate +``` + +With VS Code: +- Open the folder in VS Code +- Run: "Dev Containers: Rebuild Container" + +## Requirements + +### Host Machine + +`init-host.sh` runs on the host as the `initializeCommand` and creates `~/.claude` if it is missing. Nothing creates the contents below; they are optional, and the container starts without them: + +```bash +~/.claude/ +├── CLAUDE.md # Optional: global instructions +├── settings.json # Optional: Claude settings +├── agents/ # Optional: custom agents +├── commands/ # Optional: custom commands +└── hooks/ # Optional: event hooks +``` + +**Note**: The mount is the `~/.claude` directory itself, so a missing subdirectory or file costs nothing at launch. To create them anyway: + +```bash +mkdir -p ~/.claude/{agents,commands,hooks} +touch ~/.claude/CLAUDE.md +touch ~/.claude/settings.json +``` + +### Container + +- **pixi**, which the `Dockerfile` installs to `/usr/local/bin/pixi`; `install.sh` downloads it there itself if it is missing +- No manual configuration required + +## Assumptions + +1. **Container User**: This feature assumes the container user is `vscode` (standard for Dev Containers) + - Configuration files are mounted to `/home/vscode/.claude/` + - If your container uses a different user (e.g., `root`, `codespace`), you'll need to customize the mounts in your `devcontainer.json` + +2. **HOME Environment Variable**: Must be set on the host machine (standard on Unix systems) + +3. **Persistence**: Your host machine's `~/.claude/` directory should persist across container rebuilds + +4. **Platform**: Designed for Linux/macOS hosts + - Windows WSL2 should work + - Windows native may require path adjustments + +## How to Iterate Locally + +### Quick Changes + +1. Edit files in `.devcontainer/claude-code/`: + - `devcontainer-feature.json` - Change mounts, extensions, or metadata + - `install.sh` - Modify installation logic + - `README.md` - Update documentation + +2. Rebuild the container: + ```bash + devpod up . --recreate + ``` + +### Testing Install Script + +You can test the install script standalone, from inside the container: + +```bash +cd .devcontainer/claude-code +sudo ./install.sh +``` + +It resolves its target from `_REMOTE_USER` and `_REMOTE_USER_HOME` and falls back to `vscode`, so on a host with no `/home/vscode` it exits with an error rather than doing anything. + +### Debugging + +Check if Claude is installed: +```bash +claude --version +``` + +Check mounted files: +```bash +ls -la ~/.claude/ +``` + +Verify the config directory is mounted, writable, and pointed at: +```bash +env | grep CLAUDE_CONFIG_DIR # /home/vscode/.claude +mount | grep /home/vscode/.claude # one bind, rw +touch ~/.claude/.mount-check && rm ~/.claude/.mount-check && echo writable +``` + +The write test uses a throwaway file on purpose. Do not test the mount by appending to `CLAUDE.md`, `settings.json` or anything under `hooks/`: the mount is read-write, so the write lands on the host's real configuration and is loaded into every later Claude Code session. + +## Authentication + +### How It Works + +1. **Already Authenticated on Host**: If you have Claude Code set up on your host machine, credentials are automatically shared with the container +2. **First-Time Setup**: Run `claude` on the **host** and follow the OAuth flow there: + - The CLI provides an OAuth URL + - Open the URL in your browser + - Click "Authorize" + - The callback completes, because the CLI and the browser are both on the host + - Credentials are saved to `~/.claude/.credentials.json`, and the mount carries them into every container + +### OAuth Callback Behavior + +The OAuth flow opens a local callback server. In containers, this can behave differently: +- **VS Code Dev Containers**: Usually handles port forwarding automatically +- **DevPod**: May require manual code pasting if callback doesn't complete +- **SSH/Remote**: Callback URL opens in your local browser + +### Troubleshooting Authentication + +**"Paste code here" prompt hangs forever:** +- Check that `~/.claude/.credentials.json` exists on your host with proper permissions (`600`) +- Try authenticating on your host machine first, then restart `claude` in the container -- the mount is live, so no rebuild is needed +- If the callback fails, look for the authorization code in the URL after clicking "Authorize" + +**Credentials not persisting:** +- Ensure the `.credentials.json` file exists on your host before rebuilding +- Check file permissions: `chmod 600 ~/.claude/.credentials.json` + +**Setup wizard runs on every rebuild (theme selection, OAuth):** + +This happens because Claude tracks setup completion **per-workspace**, not globally. + +**Quick fix:** +```bash +# On your HOST machine: +# Set the onboarding flag for your workspace (`devpod list` shows its name) +jq '.projects["/workspaces/"].projectOnboardingSeenCount = 1' ~/.claude/.claude.json > ~/.claude/.claude.json.tmp +mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json + +# Also ensure themeMode is set (if needed) +jq '. + {themeMode: "dark"}' ~/.claude/.claude.json > ~/.claude/.claude.json.tmp +mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json + +# Then restart `claude` in the container -- the mount is live, so no rebuild is needed +``` + +**Root cause:** Claude tracks setup wizard completion per-workspace in `.claude.json` under `.projects["/workspaces/"].projectOnboardingSeenCount`. When this is `0`, the setup wizard runs. Set it to `1` to mark setup as complete. + +**Finding ``:** it is the devpod workspace name, and the container mounts the repo at `/workspaces/`. `devpod list` shows the name, and `dl --ls` shows it for workspaces devlaunch created. + +## Modifying Configuration + +The container writes to the same `~/.claude` as the host, so an edit made in either place is an edit to the one shared configuration. + +Editing from the **host** is still the better habit: `~/.claude/settings.json`, `~/.claude/CLAUDE.md` and the rest are yours across every branch container, and a change made on the host is one you meant to make. The mount is live, so restarting `claude` picks up a change -- no rebuild needed. + +## What Would Change Before Publishing to GHCR + +If you wanted to publish this feature to GitHub Container Registry later: + +### 1. Repository Structure + +Move from `.devcontainer/claude-code/` to a dedicated repo: + +``` +anthropics/devcontainer-features/ +└── src/ + └── claude-code/ + ├── devcontainer-feature.json + ├── install.sh + └── README.md +``` + +### 2. Metadata Updates + +In `devcontainer-feature.json`: + +```json +{ + "id": "claude-code", + "version": "1.0.0", // Semantic versioning + "documentationURL": "https://github.com/anthropics/devcontainer-features/tree/main/src/claude-code", + // ... rest of config +} +``` + +### 3. Testing Infrastructure + +Add GitHub Actions workflow (`.github/workflows/test.yaml`): + +```yaml +- name: "Create test prerequisites" + run: | + mkdir -p ~/.claude/agents + mkdir -p ~/.claude/commands + mkdir -p ~/.claude/hooks + touch ~/.claude/settings.json + touch ~/.claude/CLAUDE.md +``` + +### 4. Publishing Workflow + +Add release workflow to build and push to `ghcr.io/anthropics/devcontainer-features/claude-code:1` + +### 5. Reference Change + +Users would then reference it as: + +```json +{ + "features": { + "ghcr.io/anthropics/devcontainer-features/claude-code:1": {} + } +} +``` + +Instead of `"../claude-code": {}` + +## Optional: Future Composition + +### Splitting into Modular Features + +This feature could be split into: + +1. **`claude-code-core`**: Just CLI installation, no mounts + ```json + { + "features": { + "./claude-code-core": {} + } + } + ``` + +2. **`claude-code-mounts`**: Just configuration mounts (requires `claude-code-core`) + ```json + { + "features": { + "./claude-code-core": {}, + "./claude-code-mounts": {} + } + } + ``` + +Benefits: +- Users can install CLI without mounts (useful for Codespaces or CI) +- More flexible composition +- Easier to maintain and test separately + +### Composition with Custom Features + +You could create a personal feature that extends this: + +```json +// .devcontainer/my-claude-setup/devcontainer-feature.json +{ + "id": "my-claude-setup", + "installsAfter": ["./claude-code"], + "customizations": { + "vscode": { + "settings": { + "claude.someCustomSetting": "value" + } + } + } +} +``` + +Then use both: + +```json +{ + "features": { + "./claude-code": {}, + "./my-claude-setup": {} + } +} +``` + +## Troubleshooting + +### OAuth callback hangs at "Paste code here" + +**Problem**: Browser clicks "Authorize" but container never receives the callback. + +**Solution**: Run `claude` on the host instead and let the container read the credentials it writes to `~/.claude`. If you need the login to happen inside the container, add `--network=host` and accept its costs -- see "Why You Might Opt Into `--network=host`" above. + +### Interactive `claude` asks for authentication but `claude --print` works + +**Problem**: You're authenticated (credentials mounted) but interactive mode prompts for login. + +**Root cause**: Interactive mode tried to start an OAuth flow, which means it found no usable credentials under `CLAUDE_CONFIG_DIR`. + +**Solution**: Authenticate on the host so `~/.claude/.credentials.json` holds a live token, and check that `~/.claude` is actually mounted and `CLAUDE_CONFIG_DIR` points at it. + +### VS Code extensions don't install with `--network=host` + +**Known Issue**: [Using runArgs network=host prevents extensions from installing](https://github.com/microsoft/vscode-remote-release/issues/9212) + +**Workarounds:** +1. **Rebuild without runArgs first**, let extensions install, then add runArgs (extensions persist) +2. **Authenticate on host**, mount credentials, remove runArgs (no OAuth needed in container) +3. **Manually install extensions** after container starts + +### `~/.claude` missing on the host + +**Solution**: The `initializeCommand` (`init-host.sh`) creates it before the container starts. To lay out the rest yourself: + +```bash +mkdir -p ~/.claude/{agents,commands,hooks} +touch ~/.claude/CLAUDE.md ~/.claude/settings.json +``` + +## Security Notes + +The whole `~/.claude` directory is bind-mounted read-write as one mount, so nothing in it is held back from the container. + +### What Code in the Container Can Read and Write +- **`.credentials.json`**: the live OAuth access and refresh tokens +- **`.claude.json`**: account info, user ID, per-workspace onboarding and trust state +- **`CLAUDE.md`**, **`settings.json`**, **`agents/`**, **`commands/`**, **`hooks/`**: the host's copies, in place + +`install.sh` runs when the image is built, so the two `600` credential files it creates live in the image and the bind mount covers them at runtime. Permissions on the host's real files are whatever the host set -- see Issue 6 in TROUBLESHOOTING.md. Nothing here restricts code running inside the container, which runs as the user those files belong to. + +### The Consequence Worth Naming +`hooks/` and `settings.json` are executed by Claude Code wherever it runs. Code in the container that writes there gets its content executed on the **host**, the next time Claude Code starts there -- a `postCreateCommand` from a repository you have not read reaches that far. The container also carries the live Claude credentials and, under `dl`, a `GH_TOKEN` with repo and workflow scopes, so it is not a confidentiality boundary either. + +### Why It Is Accepted +One shared config directory is what makes auth work across the host and every branch container without a re-auth, and it is why a container never re-onboards. That is the trade; the isolation buys reproducible dependencies and non-colliding concurrent work, not protection from hostile code. `blooop/wayfinder`'s `.devcontainer/devcontainer.json` writes out the same threat model above its `mounts` block. Treat a repository you launch this way as code you are running with your own credentials, because that is what it is. + +Whether the non-credential paths should become read-only binds is an open question, not a settled one. + +See related security discussions: +- [anthropics/claude-code#4478](https://github.com/anthropics/claude-code/issues/4478) +- [anthropics/claude-code#2350](https://github.com/anthropics/claude-code/issues/2350) +- Per-file read-only approach this feature does not implement: [PR #25](https://github.com/anthropics/devcontainer-features/pull/25) + +## Reference + +- **Dev Container Features Spec**: https://containers.dev/implementers/features/ +- **Local Features**: https://containers.dev/implementers/features/#local-features +- **Based on PR**: https://github.com/anthropics/devcontainer-features/pull/25 +- **Upstream Features**: https://github.com/anthropics/devcontainer-features + +## License + +Based on the Anthropic devcontainer-features repository (MIT License). diff --git a/.devcontainer/claude-code/TROUBLESHOOTING.md b/.devcontainer/claude-code/TROUBLESHOOTING.md new file mode 100644 index 0000000..59e2156 --- /dev/null +++ b/.devcontainer/claude-code/TROUBLESHOOTING.md @@ -0,0 +1,381 @@ +# Claude Code Dev Container Feature - Troubleshooting Guide + +## Quick Reference + +### Files on the Host + +`init-host.sh` creates `~/.claude` as the `initializeCommand`. Everything inside it is optional, and only `.credentials.json` is load-bearing for an authenticated `claude`. + +```bash +~/.claude/ # one bind mount, read-write, shared with every container +├── .credentials.json # OAuth tokens +├── .claude.json # Account info, setup state +├── CLAUDE.md # Global instructions +├── settings.json # Settings +├── agents/ # Custom agents +├── commands/ # Custom commands +└── hooks/ # Event hooks +``` + +Every one of these is writable from inside the container, and a write lands on the host. See "Security Considerations" below for what follows from that. + +### Critical Configuration in devcontainer.json + +```json +{ + "image": "ghcr.io/blooop/python_template/devcontainer:latest", + "containerEnv": { + "CLAUDE_CONFIG_DIR": "/home/vscode/.claude", + "XDG_CONFIG_HOME": "/home/vscode/.config", + "XDG_CACHE_HOME": "/home/vscode/.cache", + "XDG_DATA_HOME": "/home/vscode/.local/share" + }, + "mounts": [ + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind" + ] +} +``` + +The `../claude-code` feature is declared in `.devcontainer/ci/devcontainer.json`, the config CI builds the image from, so there is no `features` block here -- and no `runArgs` either. + +## Common Issues and Solutions + +### Issue 1: Setup Wizard Runs on Every Container Rebuild + +**Symptoms:** +- Interactive `claude` shows theme selection screen +- After selecting theme, asks for OAuth authentication +- Happens every time you rebuild the container + +**Root Cause:** +Claude tracks setup completion per-workspace in `.claude.json`: +```json +{ + "projects": { + "/workspaces/": { + "projectOnboardingSeenCount": 0 // ← This! + } + } +} +``` + +`` is the devpod workspace name -- `devpod list` shows it, and `dl --ls` shows it for workspaces devlaunch created. + +**Solution:** +```bash +# On HOST machine, set a high count to skip wizard +jq '.projects["/workspaces/"].projectOnboardingSeenCount = 999' \ + ~/.claude/.claude.json > ~/.claude/.claude.json.tmp +mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json + +# Also ensure themeMode is set (global setting) +jq '. + {themeMode: "dark"}' ~/.claude/.claude.json > ~/.claude/.claude.json.tmp +mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json + +# Then restart `claude` in the container -- the mount is live, so no rebuild is needed +``` + +**Why 999?** The field is `projectOnboardingSeenCount` - it increments each time you see the wizard. Setting it high tells Claude "this workspace has been onboarded many times, skip the wizard." + +**Verification:** +```bash +# From the host, shell into the container +devpod ssh +claude # Should go straight to interactive mode without wizard +``` + +### Issue 2: OAuth Callback Hangs at "Paste code here" + +**Symptoms:** +- Browser opens, you click "Authorize" +- CLI shows "Paste code here >" and waits forever +- Browser callback URL fails to connect + +**Root Cause:** +OAuth callback server runs inside container on a random port (e.g., `localhost:35673`). Your browser tries to connect to that port on the HOST, but the container's port isn't accessible. + +**Solution:** +Authenticate on the host, where the browser can reach the callback port, and let the container read the resulting credentials through the `~/.claude` bind mount. No OAuth flow then runs in the container at all. + +**Alternative, if you want the login to happen inside the container:** +Add `--network=host` to devcontainer.json: + +```json +{ + "runArgs": ["--network=host"] +} +``` + +This makes the container share the host's network namespace, so ports inside the container are accessible from the host browser. + +**What that costs:** +VS Code extensions stop installing (known issue: [#9212](https://github.com/microsoft/vscode-remote-release/issues/9212)), the container gets full access to the host's network, and every port the container binds becomes a host port -- so two branch containers of the same repo collide on the first port they share. + +### Issue 3: `claude --print` Works But Interactive `claude` Asks for Login + +**Symptoms:** +- `echo "test" | claude --print` works without authentication +- Running just `claude` shows setup wizard or login prompt + +**Root Cause:** +Two different issues: +1. **Setup wizard** (theme/onboarding) - see Issue 1 +2. **Print mode skips workspace trust dialogs** - expected behavior + +**Solution:** +- For setup wizard: See Issue 1 +- For workspace trust: Use `--dangerously-skip-permissions` in trusted containers + +### Issue 4: Authentication Doesn't Persist After Container Rebuild + +**Symptoms:** +- You authenticate in the container +- Rebuild the container +- Have to authenticate again + +**Root Cause:** +`~/.claude` is not mounted, so `claude` wrote its credentials into the container's own filesystem and they went away with the container. + +**Solution:** + +1. **Verify the mount in the container:** + ```bash + # From the host, shell into the container (`devpod list` shows ``, + # `dl --ls` for workspaces devlaunch created) + devpod ssh + mount | grep claude + ``` + + Should show one bind of the directory, read-write: + ``` + /dev/... on /home/vscode/.claude type ext4 (rw,...) + ``` + +2. **Check files exist on host:** + ```bash + ls -la ~/.claude/.credentials.json ~/.claude/.claude.json + ``` + +3. **Check the mount is read-write:** + A refresh has to persist, so `rw` in the line above is load-bearing. The feature declares no `ro` flag, so a read-only mount means something outside it added one. + +### Issue 5: A Container Changed the Host's Claude Configuration + +**Symptoms:** +- `~/.claude/CLAUDE.md`, `settings.json` or a file under `hooks/` differs from what you left on the host +- A hook or setting you did not write takes effect when you start `claude` on the host + +**Root Cause:** +Not a malfunction. `~/.claude` is one read-write bind of the whole directory, so the host and every container share it and anything in a container can write any of it. `hooks/` and `settings.json` are executed by Claude Code wherever it runs, so what a container leaves there runs on the host next time. + +**Solution:** +Restore the files from wherever your configuration lives -- keeping `~/.claude` under version control is what makes a change like this visible and reversible. Then look at what put it there: a `postCreateCommand`, a hook, or an agent session in the container all reach that far. + +### Issue 6: File Permission Errors (600 vs 664) + +**Symptoms:** +- Cannot read credentials file +- Permission denied errors + +**Solution:** +```bash +# On HOST +chmod 600 ~/.claude/.credentials.json +chmod 600 ~/.claude/.claude.json +``` + +These files contain sensitive data and should only be readable by you. + +## Debugging Commands + +### Check Authentication Status + +```bash +# In container +cat ~/.claude/.credentials.json | jq '.claudeAiOauth.accessToken' | head -c 30 +# Should show: sk-ant-oat01-... + +cat ~/.claude/.claude.json | jq '.oauthAccount.emailAddress' +# Should show your email +``` + +### Verify Mounts + +```bash +# In container +mount | grep claude +# Should show one bind of /home/vscode/.claude, rw + +ls -la ~/.claude/ +# Should show files from your host +``` + +### Check Environment Variables + +```bash +# In container +env | grep -E "(CLAUDE|XDG)" | sort +``` + +Should show: +``` +CLAUDE_CONFIG_DIR=/home/vscode/.claude +XDG_CACHE_HOME=/home/vscode/.cache +XDG_CONFIG_HOME=/home/vscode/.config +XDG_DATA_HOME=/home/vscode/.local/share +``` + +### Test Claude Without Authentication + +```bash +# This should work if you're authenticated +echo "what is 2+2" | claude --print +``` + +### Check Setup State + +```bash +# On HOST +cat ~/.claude/.claude.json | jq '.projects["/workspaces/"]' +``` + +Look for: +- `projectOnboardingSeenCount`: Should be > 0 (e.g., 999) +- Check your actual workspace path matches + +### Verify Network Mode + +```bash +# On HOST +docker inspect | jq '.[0].HostConfig.NetworkMode' +# "host" only if you opted into --network=host; otherwise the default bridge network +``` + +## Complete Setup Checklist + +When setting up a new workspace: + +- [ ] `../claude-code` feature declared in `.devcontainer/ci/devcontainer.json`, so the published image carries it +- [ ] `claude` authenticated on the host, so no OAuth flow runs in the container +- [ ] Environment variables added (CLAUDE_CONFIG_DIR, XDG_*) +- [ ] Files exist on host: `.credentials.json`, `.claude.json` +- [ ] File permissions: `chmod 600` on sensitive files +- [ ] `projectOnboardingSeenCount` set to 999 in `.claude.json` +- [ ] `themeMode` set (e.g., "dark") in `.claude.json` +- [ ] Container rebuilt: `devpod up . --recreate` +- [ ] Test: `claude --print "test"` works +- [ ] Test: `claude` goes to interactive mode without wizard + +## File Explanation + +### `.credentials.json` +Contains OAuth access and refresh tokens. Format: +```json +{ + "claudeAiOauth": { + "accessToken": "sk-ant-oat01-...", + "refreshToken": "sk-ant-ort01-...", + "expiresAt": 1234567890000 + } +} +``` + +**Why writable:** Tokens need to be refreshed periodically. + +### `.claude.json` +Contains account info, feature flags, and per-workspace state. Key fields: +```json +{ + "oauthAccount": { ... }, + "userID": "...", + "themeMode": "dark", + "projects": { + "/workspaces/": { + "projectOnboardingSeenCount": 999, + "hasTrustDialogAccepted": false, + ... + } + } +} +``` + +**Why writable:** Claude updates `projectOnboardingSeenCount` and other workspace state. + +## Advanced Debugging + +### Capture Complete Claude Startup + +```bash +# In container +script -qec "timeout 3 claude 2>&1" /tmp/claude-startup.log +cat /tmp/claude-startup.log +``` + +### Compare Config Before/After + +```bash +# Before operation +cp ~/.claude/.claude.json ~/.claude/.claude.json.before + +# Do operation (e.g., run claude) + +# After +diff <(jq -S . ~/.claude/.claude.json.before) <(jq -S . ~/.claude/.claude.json) +``` + +### Check What Changed on Host + +```bash +# On HOST, monitor file changes +watch -n 1 'stat ~/.claude/.claude.json | grep Modify' +``` + +## Security Considerations + +### What the Mount Actually Is +One read-write bind of the whole `~/.claude` directory. No `ro` flag, no per-file mounts. `.credentials.json`, `.claude.json`, `CLAUDE.md`, `settings.json`, `agents/`, `commands/` and `hooks/` are all writable from inside the container, and a write lands on the host's copy. + +### The Consequence +`hooks/` and `settings.json` are executed by Claude Code wherever it runs, so content a container writes there runs on the **host** the next time Claude Code starts there -- a `postCreateCommand` from a repository nobody read is enough. The container also holds the live Claude credentials from the mount and, under `dl`, a `GH_TOKEN` carrying repo and workflow scopes. It is not a confidentiality or integrity boundary. + +### Why It Is Accepted +Sharing one config directory is what makes credentials work across the host and every branch container: the access token is short-lived, so a read-only or copied arrangement drifts into a re-auth, and a container that cannot write `.claude.json` re-onboards on every launch. The isolation buys reproducible dependencies and non-colliding concurrent work, not safety against hostile code. `blooop/wayfinder`'s `.devcontainer/devcontainer.json` writes out the same threat model above its `mounts` block. + +Keeping `~/.claude` under version control is the one practical measure here: it makes a change from a container visible instead of silent. + +## Known Limitations + +1. **VS Code extensions may not install with --network=host** + - Issue: https://github.com/microsoft/vscode-remote-release/issues/9212 + - Workaround: Build without runArgs first, then add it + +2. **Per-workspace setup tracking** + - Each workspace path needs its own `projectOnboardingSeenCount` + - Renaming workspace requires updating the flag + +3. **No credential isolation** + - All containers share same host credentials + - Can't use different Claude accounts per container + +4. **OAuth callback browser routing** + - Requires `--network=host` or manual code pasting + - May not work in some network environments + +## Getting Help + +If issues persist: + +1. Check `/tmp/claude/debug.log` in container +2. Run `claude --debug` for verbose output +3. Review this guide with an AI agent: + - Share: `.devcontainer/claude-code/TROUBLESHOOTING.md` + - Include: Output of debugging commands above + - Describe: Exact symptoms and when they occur + +## References + +- Dev Container Features: https://containers.dev/implementers/features/ +- Claude Code Docs: https://code.claude.com/docs/ +- deps_rocker reference: https://github.com/blooop/deps_rocker +- OAuth callback issue: https://github.com/anthropics/claude-code/issues/1529 +- Network=host issue: https://github.com/microsoft/vscode-remote-release/issues/9212 diff --git a/.devcontainer/claude-code/devcontainer-feature.json b/.devcontainer/claude-code/devcontainer-feature.json new file mode 100644 index 0000000..06b9ff5 --- /dev/null +++ b/.devcontainer/claude-code/devcontainer-feature.json @@ -0,0 +1,22 @@ +{ + "name": "Claude Code CLI", + "id": "claude-code", + "version": "0.2.0", + "description": "Installs Claude Code CLI via pixi with persistent configuration", + "options": {}, + "documentationURL": "https://github.com/anthropics/devcontainer-features", + "licenseURL": "https://github.com/anthropics/devcontainer-features/blob/main/LICENSE", + "customizations": { + "vscode": { + "extensions": [ + "anthropic.claude-code" + ] + } + }, + "containerEnv": { + "CLAUDE_CONFIG_DIR": "/home/vscode/.claude" + }, + "mounts": [ + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind" + ] +} diff --git a/.devcontainer/claude-code/init-host.sh b/.devcontainer/claude-code/init-host.sh new file mode 100755 index 0000000..7e123bf --- /dev/null +++ b/.devcontainer/claude-code/init-host.sh @@ -0,0 +1,6 @@ +#!/bin/sh +# Initialize Claude Code host directory for devcontainer bind mount +# This script runs on the HOST before the container is created. +# mkdir -p is idempotent - it only creates if missing, won't clobber existing. + +mkdir -p "$HOME/.claude" diff --git a/.devcontainer/claude-code/install.sh b/.devcontainer/claude-code/install.sh new file mode 100755 index 0000000..a52d2b4 --- /dev/null +++ b/.devcontainer/claude-code/install.sh @@ -0,0 +1,184 @@ +#!/bin/bash +set -euo pipefail + +# Claude Code CLI Local Feature Install Script +# Installs Claude Code via pixi and sets up configuration directories + +# Global variables set by resolve_target_home +TARGET_USER="" +TARGET_HOME="" + +# Function to resolve target user and home directory with validation +# Sets TARGET_USER and TARGET_HOME global variables +resolve_target_home() { + TARGET_USER="${_REMOTE_USER:-vscode}" + TARGET_HOME="${_REMOTE_USER_HOME:-}" + + # If _REMOTE_USER_HOME is not set, try to infer from current user or /home/ + if [ -z "${TARGET_HOME}" ]; then + if [ "$(id -un 2>/dev/null)" = "${TARGET_USER}" ] && [ -n "${HOME:-}" ]; then + TARGET_HOME="${HOME}" + elif [ -d "/home/${TARGET_USER}" ]; then + TARGET_HOME="/home/${TARGET_USER}" + fi + fi + + # If TARGET_HOME is set but doesn't exist, try fallbacks + if [ -n "${TARGET_HOME}" ] && [ ! -d "${TARGET_HOME}" ]; then + if [ -n "${HOME:-}" ] && [ -d "$HOME" ]; then + echo "Warning: TARGET_HOME '${TARGET_HOME}' does not exist, falling back to \$HOME: $HOME" >&2 + TARGET_HOME="$HOME" + elif [ -d "/home/${TARGET_USER}" ]; then + echo "Warning: TARGET_HOME '${TARGET_HOME}' does not exist, falling back to /home/${TARGET_USER}" >&2 + TARGET_HOME="/home/${TARGET_USER}" + fi + fi + + # Ensure we ended up with a valid, existing home directory + if [ -z "${TARGET_HOME}" ] || [ ! -d "${TARGET_HOME}" ]; then + echo "Error: could not determine a valid home directory for user '${TARGET_USER}'." >&2 + echo "Checked _REMOTE_USER_HOME ('${_REMOTE_USER_HOME:-}'), \$HOME ('${HOME:-}'), and /home/${TARGET_USER}." >&2 + exit 1 + fi +} + +# Function to install pixi if not found +install_pixi() { + echo "Installing pixi..." + + # Detect architecture + case "$(uname -m)" in + x86_64|amd64) ARCH="x86_64" ;; + aarch64|arm64) ARCH="aarch64" ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; + esac + + # Download and install pixi + curl -fsSL "https://github.com/prefix-dev/pixi/releases/latest/download/pixi-${ARCH}-unknown-linux-musl" -o /usr/local/bin/pixi + chmod +x /usr/local/bin/pixi + + echo "pixi installed successfully" + pixi --version +} + +# Function to install Claude Code CLI via pixi +install_claude_code() { + echo "Installing Claude Code CLI via pixi..." + + # Install pixi if not available + if ! command -v pixi >/dev/null; then + install_pixi + fi + + # Resolve target user and home (sets TARGET_USER and TARGET_HOME) + resolve_target_home + + # Install with pixi global from blooop channel + # Run as target user so it installs to their home directory + if [ "$(id -u)" -eq 0 ] && [ "$TARGET_USER" != "root" ]; then + su - "$TARGET_USER" -c "pixi global install --channel https://prefix.dev/blooop claude-shim" + else + pixi global install --channel https://prefix.dev/blooop claude-shim + fi + + # Add pixi bin path to user's profile if not already there + local profile="$TARGET_HOME/.profile" + # shellcheck disable=SC2016 + local pixi_path_line='export PATH="$HOME/.pixi/bin:$PATH"' + if [ -f "$profile" ] && ! grep -q '\.pixi/bin' "$profile"; then + echo "$pixi_path_line" >> "$profile" + elif [ ! -f "$profile" ]; then + echo "$pixi_path_line" > "$profile" + chown "$TARGET_USER:$TARGET_USER" "$profile" 2>/dev/null || true + fi + + # Workaround: pixi trampoline fails for bash scripts, so add env bin directly + # This conditionally adds the path only if the env exists + # shellcheck disable=SC2016 + local env_path_line='[ -d "$HOME/.pixi/envs/claude-shim/bin" ] && export PATH="$HOME/.pixi/envs/claude-shim/bin:$PATH"' + if [ -f "$profile" ] && ! grep -q 'pixi/envs/claude-shim' "$profile"; then + echo "# Workaround: pixi trampoline fails for bash scripts" >> "$profile" + echo "$env_path_line" >> "$profile" + fi + + # Verify installation by checking the trampoline exists (don't run it - that triggers download) + local pixi_bin_path="$TARGET_HOME/.pixi/bin" + local claude_bin="$pixi_bin_path/claude" + if [ -x "$claude_bin" ]; then + echo "Claude Code CLI installed successfully!" + echo "(Claude binary will be downloaded on first run)" + return 0 + else + echo "ERROR: Claude Code CLI installation failed! Binary not found at $claude_bin" + return 1 + fi +} + +# Function to create Claude configuration directories +create_claude_directories() { + echo "Creating Claude configuration directories..." + + # Resolve target user and home (sets TARGET_USER and TARGET_HOME) + resolve_target_home + + echo "Target home directory: $TARGET_HOME" + echo "Target user: $TARGET_USER" + + # Create the main .claude directory and subdirectories + mkdir -p "$TARGET_HOME/.claude" + mkdir -p "$TARGET_HOME/.claude/agents" + mkdir -p "$TARGET_HOME/.claude/commands" + mkdir -p "$TARGET_HOME/.claude/hooks" + + # Create empty config files if they don't exist + if [ ! -f "$TARGET_HOME/.claude/.credentials.json" ]; then + echo "{}" > "$TARGET_HOME/.claude/.credentials.json" + chmod 600 "$TARGET_HOME/.claude/.credentials.json" + fi + + if [ ! -f "$TARGET_HOME/.claude/.claude.json" ]; then + echo "{}" > "$TARGET_HOME/.claude/.claude.json" + chmod 600 "$TARGET_HOME/.claude/.claude.json" + fi + + # Set proper ownership + if [ "$(id -u)" -eq 0 ]; then + chown -R "$TARGET_USER:$TARGET_USER" "$TARGET_HOME/.claude" || true + fi + + echo "Claude directories created successfully" +} + +# Main script +main() { + echo "=========================================" + echo "Activating feature 'claude-code' (local)" + echo "=========================================" + + # Resolve target user and home (sets TARGET_USER and TARGET_HOME) + resolve_target_home + + local claude_bin="$TARGET_HOME/.pixi/bin/claude" + + # Install Claude Code CLI + if [ -x "$claude_bin" ]; then + echo "Claude Code CLI is already installed" + else + install_claude_code || exit 1 + fi + + # Create Claude configuration directories + create_claude_directories + + echo "=========================================" + echo "Claude Code feature activated successfully!" + echo "=========================================" + echo "" + echo "Configuration is bind-mounted from the host (~/.claude)" + echo "and persists across container rebuilds." + echo "" + echo "To authenticate, run 'claude' and follow the OAuth flow." + echo "" +} + +main diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index e8bda1f..c933c93 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,32 +1,117 @@ { - "name": "python_template", - "build": { - "dockerfile": "Dockerfile", - "context": ".." - }, - "customizations": { - "vscode": { - "extensions": [ - "ms-python.python", - "ms-python.vscode-pylance", - "ms-python.pylint", - "njpwerner.autodocstring", - "charliermarsh.ruff", - "mhutchie.git-graph", - "eamodio.gitlens", - "tamasfe.even-better-toml", - "Codium.codium", - "ms-azuretools.vscode-docker", - "ryanluker.vscode-coverage-gutters" - ] - } - }, - //docker in docker is included in the pixi devcontainer example, but disabling for the moment due to security issues - // "features": { - // "ghcr.io/devcontainers/features/docker-in-docker:2": {} - // }, - "mounts": [ - "source=${localWorkspaceFolderBasename}-pixi,target=${containerWorkspaceFolder}/.pixi,type=volume" - ], - "postCreateCommand": "sudo chown vscode .pixi && pixi install" + // Uses the template's prebuilt image by default for fast startup. + // To build locally: pixi run dev-use-local + // To use this repo's own prebuilt image: pixi run dev-use-prebuilt + // "build": { + // "dockerfile": "Dockerfile", + // "context": ".." + // }, + "image": "ghcr.io/blooop/python_template/devcontainer:latest", + + // There is deliberately no "features" block. The claude-code feature is + // already baked into the image above: .devcontainer/ci/devcontainer.json, + // which is the config CI builds that image from, declares + // "../claude-code". Declaring it again here made the devcontainer spec + // build a derived image on the first launch of every branch, reinstalling + // something the pulled image already carried -- so the "prebuilt image for + // fast startup" above still paid for a build. With this block gone the + // launch is a pull and nothing else. + // + // Adding a feature back here is allowed and costs that derived build again. + // If that ever becomes the normal case, the fix is devlaunch's design -- + // a "build:" block plus customizations.devpod.prebuildRepository, which + // caches the fully assembled image, features included. That needs CI to + // publish with `devpod build` rather than the devcontainers/ci action; + // devpod's prebuild hash is computed from the Dockerfile and build context, + // so it does not apply to an "image:" config like this one at all. + + "initializeCommand": ".devcontainer/claude-code/init-host.sh", + + // There is deliberately no "runArgs": ["--network=host"]. + // + // A container per branch is the reason these repos are launched with `dl`, + // and host networking takes it away: the container joins the host's network + // namespace, so every port a test or dev server binds is a host port and + // two branches of this repo collide on the first one they share. The + // default bridge network keeps them apart at no cost -- nothing in this + // template reaches for the host's network. + // + // Two sibling repos keep the flag on purpose and should not be "fixed" to + // match this file: homeassistant-config (mDNS/discovery needs the host LAN) + // and colcon-runner (ROS 2 DDS multicast discovery). + + "customizations": { + "vscode": { + "settings": {}, + "extensions": [ + "ms-python.python", + "ms-python.vscode-pylance", + "jjjermiah.pixi-vscode", + "charliermarsh.ruff", + "tamasfe.even-better-toml", + "mhutchie.git-graph", + "anthropic.claude-code" + ] + } + }, + + "containerEnv": { + "CLAUDE_CONFIG_DIR": "/home/vscode/.claude", + "XDG_CONFIG_HOME": "/home/vscode/.config", + "XDG_CACHE_HOME": "/home/vscode/.cache", + "XDG_DATA_HOME": "/home/vscode/.local/share" + }, + + // Three mounts. The pixi volume keeps the environment out of the bind- + // mounted workspace; the other two share host credentials. + // + // ~/.config/gh is here because it works. `gh` uses a system keyring when + // one is available and falls back to hosts.yml when it is not, and on this + // host it falls back: hosts.yml carries a real oauth_token, and inside a + // container built from this config -- with GH_TOKEN and GITHUB_TOKEN unset + // -- `gh auth status` reports "Logged in to github.com account blooop + // (/home/vscode/.config/gh/hosts.yml)". This mount is the only thing that + // gives `gh` a login in a container opened WITHOUT `dl`: a plain + // `devpod up`, or VS Code's Reopen in Container. Under `dl` it is + // redundant but harmless -- `dl` forwards GH_TOKEN from the host (from + // GH_TOKEN, GITHUB_TOKEN or `gh auth token`, whichever answers first), and + // devpod applies workspace env after the devcontainer's own, so where both + // are present the forwarded token is the one `gh` uses. + // + // ~/.ssh is deliberately not mounted, for reasons that have nothing to do + // with the above. devpod forwards an ssh agent of its own, at a socket path + // it chooses -- verified in a live container: `ssh-add -l` lists the host's + // key and `git ls-remote` against a git@github.com: origin succeeds with an + // empty ~/.ssh -- so the private key does not need to be in here at all. + // Mounting the directory also wrote `Host .devpod` blocks onto the + // developer's real ssh config, naming a ProxyCommand binary that exists + // only inside the container, and those outlived the container. SSH_AUTH_SOCK + // is gone from containerEnv for the same reason: hardcoding + // /home/vscode/.ssh/agent.sock overrode the socket devpod actually + // forwards with a path nothing fills. + "mounts": [ + "source=${localWorkspaceFolderBasename}-pixi,target=${containerWorkspaceFolder}/.pixi,type=volume", + "source=${localEnv:HOME}/.config/gh,target=/home/vscode/.config/gh,type=bind", + "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind" + ], + + "postCreateCommand": ".devcontainer/post-create.sh", + + // Last in the file on purpose, and it is the one line here that is load + // bearing for every repo cut from this template. + // + // "name" is the only per-repo value in this file, so it is the only line a + // child repo edits -- and while it sat on the line above the image/build + // block, which is the block this template changes most, every + // `pixi run update-from-template-repo` conflicted on this file. git cannot + // split adjacent hunks, so the child's one-line name edit collided with the + // template's block edit every time. Measured against blooop/dbw: reverting + // only the name made the same merge clean. + // + // At the end of the file the two edits are separate hunks and merge without + // conflict -- the child keeps its name, and template changes to the image, + // the mounts and postCreateCommand all land on their own. Moving it costs + // one restructuring conflict per child, once; leaving it here costs a + // conflict on this file forever. + "name": "scratch_env" } diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 0000000..fc32480 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# postCreateCommand for this repo and every repo cut from this template. +# +# It lives in a script rather than inline in devcontainer.json for two reasons: +# the steps below have to differ per repo (not every descendant defines every +# pixi task), and a one-line string in the JSON is a permanent merge-conflict +# surface for children pulling template updates. +set -euo pipefail + +# 1. The .pixi volume is created by docker, owned by root. +sudo chown vscode .pixi + +# 2. Seed known_hosts for the forge this clone actually uses. +# +# devpod forwards an ssh agent, so auth works without the host's ~/.ssh being +# mounted -- but host identity does not come with it. Without a known_hosts +# entry the first git operation over an ssh remote fails with "Host key +# verification failed": an interactive user gets a yes/no prompt, anything +# non-interactive (a script, an agent, CI in the container) just dies. +host=$(git config --get remote.origin.url 2>/dev/null | + sed -nE 's#^(ssh://)?git@([^:/]+).*#\2#p') || true +if [ -n "${host:-}" ]; then + mkdir -p ~/.ssh && chmod 700 ~/.ssh + touch ~/.ssh/known_hosts && chmod 600 ~/.ssh/known_hosts + if ! ssh-keygen -F "$host" >/dev/null 2>&1; then + if scanned=$(ssh-keyscan -T 10 -t rsa,ecdsa,ed25519 "$host" 2>/dev/null) && + [ -n "$scanned" ]; then + printf '%s\n' "$scanned" >> ~/.ssh/known_hosts + sort -u -o ~/.ssh/known_hosts ~/.ssh/known_hosts + echo "post-create: seeded known_hosts for $host" + else + # Non-fatal: no network at postCreate must not fail container creation. + echo "post-create: could not reach $host, skipping known_hosts" >&2 + fi + fi +fi + +# 3. The environment itself. +pixi install + +# 4. Optional tasks. A descendant that does not define one simply skips it -- +# hardcoding `pixi run prek-install` here fails container creation outright with +# exit 127 on every repo that lacks the task, which is most of them. +# `pixi task list` prints to stderr, not stdout -- redirecting it to /dev/null +# silently yields an empty list and skips tasks the repo really does define. +tasks=$(pixi task list --summary 2>&1 | tr ' ,' '\n\n' || true) +for task in prek-install; do + if grep -qx "$task" <<<"$tasks"; then + echo "post-create: running '$task'" + pixi run "$task" + else + echo "post-create: no '$task' task in this repo, skipping" + fi +done