From fcf50aeb01d622981af605d1fad2aee914cba28c Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Mon, 24 Aug 2026 18:24:03 +0100 Subject: [PATCH 1/9] fix(devcontainer): make launches zero-build and stop the merge conflict Three changes, each with its reasoning in the file itself: - Drop the runtime "features" block. The claude-code feature is already baked into the published image by .devcontainer/ci/devcontainer.json, so declaring it again made the spec build a derived image on the first launch of every branch -- the "prebuilt image for fast startup" still paid for a build. - Drop "runArgs": ["--network=host"]. A container per branch is the point of launching with dl; host networking makes every bound port a host port so two branches collide. homeassistant-config and colcon-runner keep the flag on purpose (mDNS discovery, ROS 2 DDS) and are not being changed. - Drop the ~/.ssh and ~/.config/gh mounts, following the precedent already reasoned out in blooop/wayfinder. The gh mount never worked (gh keeps its token in the system keyring; dl supplies GH_TOKEN instead). The ~/.ssh directory mount wrote Host .devpod blocks onto the developer's real ssh config naming a ProxyCommand binary that only exists in the container. SSH_AUTH_SOCK is removed from containerEnv with it. Also move "name" to the end of the file. It is the only per-repo value here, so it is the only line a child repo edits, and while it sat directly above the image/build block -- the block this template changes most -- every `pixi run update-from-template-repo` conflicted on this file, because git cannot split adjacent hunks. 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 cleanly. Also scope the CI build context to .devcontainer. The Dockerfile COPYs nothing, so a repo-root context was hashed and uploaded for no reason. --- .devcontainer/ci/devcontainer.json | 2 +- .devcontainer/devcontainer.json | 90 +++++++++++++++++++++++++----- 2 files changed, 76 insertions(+), 16 deletions(-) diff --git a/.devcontainer/ci/devcontainer.json b/.devcontainer/ci/devcontainer.json index 8ce6832..884dff1 100644 --- a/.devcontainer/ci/devcontainer.json +++ b/.devcontainer/ci/devcontainer.json @@ -2,7 +2,7 @@ "name": "python_template-build", "build": { "dockerfile": "../Dockerfile", - "context": "../.." + "context": "." }, "features": { "../claude-code": {}, diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index fc2a97c..f9a23ac 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,4 @@ { - "name": "python_template", - // 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 @@ -10,13 +8,38 @@ // }, "image": "ghcr.io/blooop/python_template/devcontainer:latest", - "features": { - "./claude-code": {}, - // "ghcr.io/devcontainers/features/docker-in-docker:2": {}, - // "ghcr.io/devcontainers/features/common-utils:2": {} - }, + // 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": {}, @@ -31,21 +54,58 @@ ] } }, - "runArgs": [ - "--network=host" - ], + "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", - "SSH_AUTH_SOCK": "/home/vscode/.ssh/agent.sock" + "XDG_DATA_HOME": "/home/vscode/.local/share" }, + + // Two mounts, and the two that are gone were removed for reasons rather + // than tidiness -- both follow the precedent already reasoned out in + // blooop/wayfinder's devcontainer.json. + // + // ~/.config/gh is gone because it never worked: `gh` keeps its token in the + // system keyring, so the mounted hosts.yml carries no oauth_token and + // `gh auth status` inside the container reports the token as invalid. + // GitHub auth arrives as GH_TOKEN instead, which `dl` forwards into every + // workspace it starts (from GH_TOKEN, GITHUB_TOKEN or `gh auth token`, + // whichever answers first). Opened by something other than `dl` -- a plain + // `devpod up`, or VS Code's Reopen in Container -- this container has no + // `gh` login; export GH_TOKEN yourself for those. + // + // ~/.ssh is gone because mounting the directory put entries on the + // developer's real config that nothing outside the container could honour: + // devpod running in here writes `Host .devpod` blocks whose + // ProxyCommand names a binary that exists only inside this container, and + // those outlived the container they pointed at. It also handed over the + // private key, which was never load-bearing -- devpod forwards git + // credentials and can forward an ssh agent, which lends the use of a key + // without copying it. SSH_AUTH_SOCK is gone from containerEnv with it, + // rather than being left as a path nothing fills. "mounts": [ "source=${localWorkspaceFolderBasename}-pixi,target=${containerWorkspaceFolder}/.pixi,type=volume", - "source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind", - "source=${localEnv:HOME}/.config/gh,target=/home/vscode/.config/gh,type=bind", "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind" ], - "postCreateCommand": "sudo chown vscode .pixi && pixi install && pixi run prek-install" + + "postCreateCommand": "sudo chown vscode .pixi && pixi install && pixi run prek-install", + + // 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": "python_template" } From e0d3ead89f50ce334b4b6b9c62e07c4ec0f1fe75 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Mon, 24 Aug 2026 18:30:14 +0100 Subject: [PATCH 2/9] docs: correct auth and network guidance after devcontainer change The previous commit dropped the features block, --network=host and the ~/.ssh and ~/.config/gh mounts from .devcontainer/devcontainer.json, which left four places in the repo describing a container that no longer exists. .devcontainer/claude-code/README.md led with "Why --network=host is Required" and an example devcontainer.json carrying runArgs and a features block. The default path is now the one the template ships: authenticate claude on the host, and the ~/.claude bind mount plus CLAUDE_CONFIG_DIR hand those credentials to every container, so no OAuth flow runs inside one. The OAuth callback explanation stays, reframed as the reason to opt into host networking, along with what that costs -- VS Code extensions stop installing (vscode-remote-release#9212), and every port the container binds becomes a host port, so two branch containers collide. The example now shows the feature declared in .devcontainer/ci/devcontainer.json, where the image is built, and a launch config with neither features nor runArgs. .devcontainer/claude-code/TROUBLESHOOTING.md repeated "add --network=host" as the fix in its quick-reference config, in Issue 2 and in the setup checklist, and told readers NetworkMode should read "host". Each now points at host authentication first and names host networking as the opt-in with its costs. AGENTS.md claimed gh authentication is shared through a mounted ~/.config/gh. That mount is gone, and it never worked anyway: gh keeps its token in the system keyring, so the mounted hosts.yml carried no oauth_token. GitHub auth arrives as GH_TOKEN, which dl forwards into every workspace it starts. A container opened by anything else -- devpod up, or VS Code's Reopen in Container -- has no gh login until you export it. .devcontainer/Dockerfile created /home/vscode/.ssh and /home/vscode/.config/gh solely to receive the two deleted mounts. Both blocks are removed. --- .devcontainer/Dockerfile | 6 -- .devcontainer/claude-code/README.md | 73 +++++++++++--------- .devcontainer/claude-code/TROUBLESHOOTING.md | 30 ++++---- AGENTS.md | 2 +- 4 files changed, 55 insertions(+), 56 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index f583589..ec622e6 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -14,9 +14,3 @@ 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 .ssh directory with proper permissions for SSH config mounts -RUN mkdir -p /home/vscode/.ssh && chmod 700 /home/vscode/.ssh - -# Create .config/gh directory for GitHub CLI config mounts -RUN mkdir -p /home/vscode/.config/gh diff --git a/.devcontainer/claude-code/README.md b/.devcontainer/claude-code/README.md index 37eb3b5..20d5cfd 100644 --- a/.devcontainer/claude-code/README.md +++ b/.devcontainer/claude-code/README.md @@ -52,45 +52,58 @@ These files **must be writable** to enable: ### Setup -Add this feature to your `devcontainer.json`: +The feature is declared where the image is built, in `.devcontainer/ci/devcontainer.json`: ```json { + "build": { + "dockerfile": "../Dockerfile", + "context": "." + }, "features": { - "./claude-code": {} + "../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" }, - "runArgs": ["--network=host"] + "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. + **Note**: Node.js is automatically installed via the `installsAfter` dependency mechanism - you don't need to explicitly add it to your features. -### Why `--network=host` is Required +### Authentication Uses Host Credentials, Not Host Networking -The `runArgs: ["--network=host"]` is **critical for OAuth authentication** to work in containers. +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. -**How OAuth works:** -1. You run `claude` → starts OAuth flow -2. Opens browser → you click "Authorize" -3. Browser redirects to `http://localhost:/callback` -4. OAuth server running in container receives the callback +### Why You Might Opt Into `--network=host` -**The problem without host networking:** -- OAuth server runs on port X **inside container** -- Browser callback goes to port X on **host's localhost** -- ❌ Container's port is not accessible from host → **callback fails** +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: -**The solution:** -- With `--network=host`, container shares host's network namespace -- OAuth server on port X in container = port X on host -- ✅ Browser callback reaches the container → **authentication succeeds** +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" -**Security note:** Host networking gives the container full network access. Only use in trusted environments. +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. -**Alternative (if host networking is not acceptable):** -- Authenticate Claude on your host machine first -- Credentials in `~/.claude/.credentials.json` are automatically shared with container -- No OAuth flow needed in container +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 @@ -377,23 +390,15 @@ Then use both: **Problem**: Browser clicks "Authorize" but container never receives the callback. -**Solution**: Add `--network=host` to your `devcontainer.json`: - -```json -{ - "runArgs": ["--network=host"] -} -``` - -See "Why `--network=host` is Required" section above for details. +**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**: Without `--network=host`, OAuth callbacks can't reach the container. +**Root cause**: Interactive mode tried to start an OAuth flow, which means it found no usable credentials under `CLAUDE_CONFIG_DIR`. -**Solution**: Add `"runArgs": ["--network=host"]` to devcontainer.json. +**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` diff --git a/.devcontainer/claude-code/TROUBLESHOOTING.md b/.devcontainer/claude-code/TROUBLESHOOTING.md index a4b0f00..8349e87 100644 --- a/.devcontainer/claude-code/TROUBLESHOOTING.md +++ b/.devcontainer/claude-code/TROUBLESHOOTING.md @@ -19,20 +19,21 @@ ```json { - "features": { - "ghcr.io/devcontainers/features/node:1": {}, - "./claude-code": {} - }, - "runArgs": ["--network=host"], + "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 @@ -89,6 +90,9 @@ claude # Should go straight to interactive mode without wizard 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 @@ -99,11 +103,8 @@ Add `--network=host` to devcontainer.json: This makes the container share the host's network namespace, so ports inside the container are accessible from the host browser. -**Trade-off:** -Using `--network=host` gives the container full network access and may prevent VS Code extensions from installing (known issue: [#9212](https://github.com/microsoft/vscode-remote-release/issues/9212)). - -**Workaround if you can't use --network=host:** -Authenticate on your host machine first, then credentials are shared via mounts. +**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 @@ -247,16 +248,15 @@ Look for: ```bash # On HOST docker inspect | jq '.[0].HostConfig.NetworkMode' -# Should show: "host" +# "host" only if you opted into --network=host; otherwise the default bridge network ``` ## Complete Setup Checklist When setting up a new workspace: -- [ ] Node.js feature added to devcontainer.json -- [ ] `./claude-code` feature added -- [ ] `runArgs: ["--network=host"]` added +- [ ] `./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 diff --git a/AGENTS.md b/AGENTS.md index b9c826c..e962028 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ This project uses a devcontainer with pixi for environment management. ### Available Tools -- **GitHub CLI (`gh`)**: Available via `pixi run gh` or directly if using a login shell. The container mounts the host's `~/.config/gh` directory, so if the user is authenticated on the host, authentication is shared automatically. +- **GitHub CLI (`gh`)**: Available via `pixi run gh` or directly if using a login shell. Authentication arrives as `GH_TOKEN`, which `dl` forwards into every workspace it starts, taking it from `GH_TOKEN`, `GITHUB_TOKEN` or `gh auth token` -- whichever answers first. The container used to mount the host's `~/.config/gh` instead, which never worked: `gh` keeps its token in the system keyring, so the mounted `hosts.yml` carried no `oauth_token`. If the container was opened by something other than `dl` -- a plain `devpod up`, or VS Code's Reopen in Container -- it has no `gh` login and you have to export `GH_TOKEN` yourself. ### Running Commands From 0fd4176b668dee45aa5ee7e61e2c4d4b27f3e479 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Mon, 24 Aug 2026 18:36:23 +0100 Subject: [PATCH 3/9] docs: describe the real ~/.claude mount instead of read-only protection that does not exist The claude-code feature declares one mount, and it is a read-write bind of the whole directory: source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind No `ro` flag appears anywhere in the feature, and there are no per-file mounts. The README and TROUBLESHOOTING guide described something else: five per-file read-only binds over CLAUDE.md, settings.json, agents/, commands/ and hooks/, said to prevent prompt injection and hook manipulation, with only .credentials.json and .claude.json writable. None of that is implemented, so both files now describe what the mount actually is -- one shared read-write config directory -- and state 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. The worst of it was a documented debugging step: echo "test" >> ~/.claude/CLAUDE.md # Should fail with "Read-only file system" It does not fail. It appends to the developer's real global instructions file, which is then loaded into every later Claude Code session. It is replaced with a check that CLAUDE_CONFIG_DIR points at the mount and that the directory is writable, using a throwaway file it deletes. Documentation only -- no mount, feature or script is changed. Whether the non-credential paths should actually become read-only binds is left open. --- .devcontainer/claude-code/README.md | 86 ++++++++------------ .devcontainer/claude-code/TROUBLESHOOTING.md | 71 +++++++--------- 2 files changed, 65 insertions(+), 92 deletions(-) diff --git a/.devcontainer/claude-code/README.md b/.devcontainer/claude-code/README.md index 20d5cfd..30cae58 100644 --- a/.devcontainer/claude-code/README.md +++ b/.devcontainer/claude-code/README.md @@ -1,13 +1,13 @@ # Claude Code CLI - Local Dev Container Feature -A local Dev Container Feature that installs the Claude Code CLI and configures it with read-only mounts to your host machine's Claude configuration. +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**: Installs the `@anthropic-ai/claude-code` npm package globally -2. **Configuration Mounting**: Mounts your host machine's Claude configuration files into the container as read-only binds +2. **Configuration Mounting**: Bind-mounts your host machine's `~/.claude` directory into the container, read-write ## What Gets Installed @@ -17,36 +17,25 @@ This feature combines two capabilities: ## What Gets Mounted -The following files and directories from your **host machine** are mounted into the container: +One mount, and it is the whole directory: -### Read-Only Mounts (Security-Protected) -- `~/.claude/CLAUDE.md` → Global project instructions -- `~/.claude/settings.json` → Claude CLI settings -- `~/.claude/agents/` → Custom agent configurations -- `~/.claude/commands/` → Command definitions -- `~/.claude/hooks/` → Event-driven shell hooks +``` +source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind +``` -These are **read-only** (`ro` flag) to prevent: -- Prompt injection attacks that could modify your Claude configuration -- Accidental modification of shared configuration from within containers -- Security issues related to hook manipulation +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. -### Read-Write Mounts (Authentication & State) -- `~/.claude/.credentials.json` → OAuth access/refresh tokens -- `~/.claude/.claude.json` → Account info, user ID, workspace setup tracking +### What That Means -These files **must be writable** to enable: -- OAuth authentication flow and token refresh -- Workspace setup state tracking (`projectOnboardingSeenCount`) -- Session continuity across container rebuilds +`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. -### Why These Must Be Writable +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. -**`.credentials.json`**: OAuth tokens need to be refreshed periodically. Claude writes updated tokens to this file. +### Why It Is Still One Read-Write Directory -**`.claude.json`**: Claude tracks per-workspace setup state here. The `projectOnboardingSeenCount` field must be writable so Claude doesn't show the setup wizard on every launch. +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. -⚠️ **Security Note**: These files contain sensitive data and are mounted read-write by necessity. They are only accessible by the container user and stored with `600` permissions. Only use this feature with trusted repositories. +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 @@ -193,11 +182,15 @@ Check mounted files: ls -la ~/.claude/ ``` -Verify mounts are read-only: +Verify the config directory is mounted, writable, and pointed at: ```bash -echo "test" >> ~/.claude/CLAUDE.md # Should fail with "Read-only file system" +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 @@ -253,14 +246,9 @@ devpod up . --recreate ## Modifying Configuration -Configuration files (except credentials) are read-only. You **cannot** modify Claude settings from within the container. +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. -To change configuration: - -1. Edit files on your **host machine**: `~/.claude/settings.json`, `~/.claude/CLAUDE.md`, etc. -2. Restart or rebuild the container to see changes - -This is by design for security (prevents prompt injection attacks). +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 @@ -420,33 +408,27 @@ touch ~/.claude/CLAUDE.md ~/.claude/settings.json ## Security Notes -This implementation makes conscious security trade-offs to enable OAuth authentication and persistent setup state: +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 -### What's Protected (Read-Only Mounts) -- **CLAUDE.md**: Prevents prompt injection attacks that could modify your global instructions -- **settings.json**: Prevents config tampering -- **agents/**, **commands/**, **hooks/**: Prevents malicious code execution through modified hooks +`install.sh` creates the two credential files with `600` permissions when they do not already exist, which keeps other users on the host out. It does not restrict code running inside the container, which runs as the user those files belong to. -### What's Writable (Necessary Trade-off) -- **`.credentials.json`**: OAuth tokens must be writable for token refresh to work -- **`.claude.json`**: Workspace state must be writable to persist `projectOnboardingSeenCount` and other setup tracking +### 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. -### Security Mitigations -- Files have `600` permissions (user-only access) -- Only use this feature in **trusted repositories** -- Container user isolation provides some protection -- Writable files are limited to authentication/state only -- All configuration and code execution files remain read-only +### 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. -### Known Risks -- A malicious process in the container could exfiltrate OAuth tokens from `.credentials.json` -- A malicious process could modify workspace state in `.claude.json` -- **Recommendation**: Only use in repositories you trust, as you would with any dev container configuration +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) -- Original read-only approach: [PR #25](https://github.com/anthropics/devcontainer-features/pull/25) +- Per-file read-only approach this feature does not implement: [PR #25](https://github.com/anthropics/devcontainer-features/pull/25) ## Reference diff --git a/.devcontainer/claude-code/TROUBLESHOOTING.md b/.devcontainer/claude-code/TROUBLESHOOTING.md index 8349e87..1e0070c 100644 --- a/.devcontainer/claude-code/TROUBLESHOOTING.md +++ b/.devcontainer/claude-code/TROUBLESHOOTING.md @@ -5,16 +5,18 @@ ### Files That Must Exist on Host ```bash -~/.claude/ -├── .credentials.json # OAuth tokens (must be writable) -├── .claude.json # Account info, setup state (must be writable) -├── CLAUDE.md # Global instructions (read-only) -├── settings.json # Settings (read-only) -├── agents/ # Custom agents (read-only) -├── commands/ # Custom commands (read-only) -└── hooks/ # Event hooks (read-only) +~/.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 @@ -129,20 +131,19 @@ Two different issues: - Have to authenticate again **Root Cause:** -`.credentials.json` or `.claude.json` is not mounted, or is mounted read-only. +`~/.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 mounts in container:** +1. **Verify the mount in the container:** ```bash devpod ssh pythontemplate mount | grep claude ``` - Should show: + Should show one bind of the directory, read-write: ``` - /dev/... on /home/vscode/.claude/.credentials.json type ext4 (rw,...) - /dev/... on /home/vscode/.claude/.claude.json type ext4 (rw,...) + /dev/... on /home/vscode/.claude type ext4 (rw,...) ``` 2. **Check files exist on host:** @@ -150,26 +151,20 @@ Two different issues: ls -la ~/.claude/.credentials.json ~/.claude/.claude.json ``` -3. **Verify files are writable (not ro):** - The mounts MUST be read-write for auth to persist. +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: "Read-only file system" Error +### Issue 5: A Container Changed the Host's Claude Configuration **Symptoms:** -- Error when trying to write to `~/.claude/CLAUDE.md` or similar -- Operations fail with "Read-only file system" - -**Expected Behavior:** -This is intentional! Security files are mounted read-only: -- `CLAUDE.md`, `settings.json`, `agents/`, `commands/`, `hooks/` → Read-only +- `~/.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 -**Why?** -Prevents prompt injection attacks that could modify your Claude configuration. +**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:** -Edit these files on your HOST machine, then restart/rebuild the container. - -Only `.credentials.json` and `.claude.json` are read-write (needed for auth and state). +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) @@ -204,7 +199,7 @@ cat ~/.claude/.claude.json | jq '.oauthAccount.emailAddress' ```bash # In container mount | grep claude -# Should show all mounted files/directories +# Should show one bind of /home/vscode/.claude, rw ls -la ~/.claude/ # Should show files from your host @@ -332,20 +327,16 @@ watch -n 1 'stat ~/.claude/.claude.json | grep Modify' ## Security Considerations -### What's Protected (Read-Only) -- `CLAUDE.md` - Prevents prompt injection -- `settings.json` - Prevents config tampering -- `agents/`, `commands/`, `hooks/` - Prevents malicious modifications +### 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. -### What's Writable (Necessary Risk) -- `.credentials.json` - OAuth tokens (necessary for auth) -- `.claude.json` - Setup state (necessary to skip wizard) +### 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. -### Mitigation -- Only use in trusted repositories -- Files have `600` permissions (user-only access) -- Container user isolation -- Regular review of `.claude.json` changes +Keeping `~/.claude` under version control is the one practical measure here: it makes a change from a container visible instead of silent. ## Known Limitations From cbff39a1ed3f6fe65f17e7e75bc6b3e16743ac16 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Mon, 24 Aug 2026 18:44:18 +0100 Subject: [PATCH 4/9] docs: correct install mechanism and remove the installsAfter claim The .devcontainer tree is about to be copied into 26 repos, so every factual statement in it had to be checked against the code beside it. Part 1, both verified against install.sh and devcontainer-feature.json: - The CLI is not the @anthropic-ai/claude-code npm package. install.sh runs `pixi global install --channel https://prefix.dev/blooop claude-shim`, downloading pixi to /usr/local/bin/pixi first if the image does not carry it. The blooop prefix.dev channel is therefore a dependency of this feature, which the README now says. install.sh only checks that the trampoline exists, so the binary arrives on the first `claude` run -- also now stated. - Removed the claim that Node.js is installed via `installsAfter`, in both places. devcontainer-feature.json declares no `installsAfter` key at all, and nothing node-related is installed because the CLI is a pixi package. The Container requirements bullet names pixi instead; nothing was invented to replace the mechanism. What the sweep found beyond that: - init-host.sh creates only ~/.claude, so "they will be created if they don't exist" was false for the subdirectories and files listed under it, and the mount being one directory bind means a missing subdirectory produces no mount warnings. Corrected in the README Requirements section, its "Mount warnings about missing files" troubleshooting entry, and the TROUBLESHOOTING quick reference. - install.sh runs at image build time, so it never touches the host. The Security Notes claim that its 600 credential files "keeps other users on the host out" was wrong; the files it creates live in the image and the bind mount covers them at runtime. Same correction to "Creates .claude/ structure in the container". - Authentication "First-Time Setup" still told you to run the OAuth flow inside the container, which the same file says three times cannot complete on the default bridge network. It now points at the host. - Two "rebuild the container" steps after editing ~/.claude on the host: the mount is live, so restarting `claude` is enough. - ci/devcontainer.json declares "../claude-code", not "./claude-code" -- fixed in three places. - The standalone `sudo ./install.sh` test is a container operation; on a host with no /home/vscode it exits with an error. --- .devcontainer/claude-code/README.md | 41 ++++++++++---------- .devcontainer/claude-code/TROUBLESHOOTING.md | 11 +++--- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/.devcontainer/claude-code/README.md b/.devcontainer/claude-code/README.md index 30cae58..7990c05 100644 --- a/.devcontainer/claude-code/README.md +++ b/.devcontainer/claude-code/README.md @@ -6,14 +6,14 @@ A local Dev Container Feature that installs the Claude Code CLI and bind-mounts This feature combines two capabilities: -1. **CLI Installation**: Installs the `@anthropic-ai/claude-code` npm package globally +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 +- **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**: Creates `.claude/` structure in the container +- **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 @@ -71,8 +71,6 @@ CI publishes that image, and the `devcontainer.json` every branch launches from 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. -**Note**: Node.js is automatically installed via the `installsAfter` dependency mechanism - you don't need to explicitly add it to your features. - ### 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. @@ -109,7 +107,7 @@ With VS Code: ### Host Machine -You should have these files/directories on your host machine (they will be created if they don't exist): +`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/ @@ -120,7 +118,7 @@ You should have these files/directories on your host machine (they will be creat └── hooks/ # Optional: event hooks ``` -**Note**: If these don't exist on your host, the container will still build successfully, but you may see mount warnings. You can create them with: +**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} @@ -130,7 +128,7 @@ touch ~/.claude/settings.json ### Container -- **Node.js 18+** and **npm** are automatically installed via the `installsAfter` dependency mechanism +- **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 @@ -163,13 +161,15 @@ touch ~/.claude/settings.json ### Testing Install Script -You can test the install script standalone: +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: @@ -196,12 +196,12 @@ The write test uses a throwaway file on purpose. Do not test the mount by append ### 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` in the container and follow the OAuth flow: - - The CLI will provide an OAuth URL - - Open the URL in your browser (on your host machine) +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 should complete automatically, or you may need to paste the code - - Credentials are saved to `~/.claude/.credentials.json` on your host + - 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 @@ -214,7 +214,7 @@ The OAuth flow opens a local callback server. In containers, this can behave dif **"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 rebuild the container +- 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:** @@ -236,8 +236,7 @@ mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json jq '. + {themeMode: "dark"}' ~/.claude/.claude.json > ~/.claude/.claude.json.tmp mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json -# Rebuild container -devpod up . --recreate +# 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["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/workspaces/pythontemplate"].projectOnboardingSeenCount`. When this is `0`, the setup wizard runs. Set it to `1` to mark setup as complete. @@ -310,7 +309,7 @@ Users would then reference it as: } ``` -Instead of `"./claude-code": {}` +Instead of `"../claude-code": {}` ## Optional: Future Composition @@ -397,9 +396,9 @@ Then use both: 2. **Authenticate on host**, mount credentials, remove runArgs (no OAuth needed in container) 3. **Manually install extensions** after container starts -### Mount warnings about missing files +### `~/.claude` missing on the host -**Solution**: Create the directories on your 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} @@ -415,7 +414,7 @@ The whole `~/.claude` directory is bind-mounted read-write as one mount, so noth - **`.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` creates the two credential files with `600` permissions when they do not already exist, which keeps other users on the host out. It does not restrict code running inside the container, which runs as the user those files belong to. +`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. diff --git a/.devcontainer/claude-code/TROUBLESHOOTING.md b/.devcontainer/claude-code/TROUBLESHOOTING.md index 1e0070c..1687c91 100644 --- a/.devcontainer/claude-code/TROUBLESHOOTING.md +++ b/.devcontainer/claude-code/TROUBLESHOOTING.md @@ -2,7 +2,9 @@ ## Quick Reference -### Files That Must Exist on Host +### 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 @@ -34,7 +36,7 @@ Every one of these is writable from inside the container, and a write lands on t } ``` -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. +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 @@ -68,8 +70,7 @@ mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json jq '. + {themeMode: "dark"}' ~/.claude/.claude.json > ~/.claude/.claude.json.tmp mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json -# Rebuild container -devpod up . --recreate +# 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." @@ -250,7 +251,7 @@ docker inspect | jq '.[0].HostConfig.NetworkMode' When setting up a new workspace: -- [ ] `./claude-code` feature declared in `.devcontainer/ci/devcontainer.json`, so the published image carries it +- [ ] `../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` From cefdb48d1407dd5d1d297f36864b8d09682039de Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Mon, 24 Aug 2026 18:48:08 +0100 Subject: [PATCH 5/9] docs: stop hardcoding the pythontemplate workspace name The claude-code README and TROUBLESHOOTING spelled out `pythontemplate` as the workspace name in ten places -- `devpod ssh pythontemplate` and `/workspaces/pythontemplate` inside the .claude.json examples. That name is right in this repo and wrong in every copy of this tree, and the whole .devcontainer/ directory is about to be copied verbatim into 26 other repositories where no such workspace or ssh host exists. scripts/rename_project.sh cannot rescue those copies: it seds the string `python_template`, which never matches `pythontemplate`. blooop/bencher shows the failure mode already -- its pyproject.toml still runs `ssh pythontemplate.devpod` in a repo called bencher. Shelling into the container now points at `pixi run dev`, which brings the workspace up and sshes in using whatever name the repo actually has. The .claude.json paths, where no task can stand in, use a `` placeholder plus a note that `devpod list` shows the real name (`dl --ls` for workspaces devlaunch created). --- .devcontainer/claude-code/README.md | 8 ++++---- .devcontainer/claude-code/TROUBLESHOOTING.md | 16 +++++++++------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/.devcontainer/claude-code/README.md b/.devcontainer/claude-code/README.md index 7990c05..4e358b1 100644 --- a/.devcontainer/claude-code/README.md +++ b/.devcontainer/claude-code/README.md @@ -228,8 +228,8 @@ This happens because Claude tracks setup completion **per-workspace**, not globa **Quick fix:** ```bash # On your HOST machine: -# Set the onboarding flag for your workspace -jq '.projects["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/workspaces/pythontemplate"].projectOnboardingSeenCount = 1' ~/.claude/.claude.json > ~/.claude/.claude.json.tmp +# 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) @@ -239,9 +239,9 @@ 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["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/workspaces/pythontemplate"].projectOnboardingSeenCount`. When this is `0`, the setup wizard runs. Set it to `1` to mark setup as complete. +**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. -**For future workspaces:** Replace `/workspaces/pythontemplate` with your actual container workspace path. +**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 diff --git a/.devcontainer/claude-code/TROUBLESHOOTING.md b/.devcontainer/claude-code/TROUBLESHOOTING.md index 1687c91..1e11b33 100644 --- a/.devcontainer/claude-code/TROUBLESHOOTING.md +++ b/.devcontainer/claude-code/TROUBLESHOOTING.md @@ -52,17 +52,19 @@ Claude tracks setup completion per-workspace in `.claude.json`: ```json { "projects": { - "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/workspaces/pythontemplate": { + "/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["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/workspaces/pythontemplate"].projectOnboardingSeenCount = 999' \ +jq '.projects["/workspaces/"].projectOnboardingSeenCount = 999' \ ~/.claude/.claude.json > ~/.claude/.claude.json.tmp mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json @@ -77,8 +79,8 @@ mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json **Verification:** ```bash -# In container -devpod ssh pythontemplate +# From the host, shell into the container +pixi run dev claude # Should go straight to interactive mode without wizard ``` @@ -138,7 +140,7 @@ Two different issues: 1. **Verify the mount in the container:** ```bash - devpod ssh pythontemplate + pixi run dev mount | grep claude ``` @@ -232,7 +234,7 @@ echo "what is 2+2" | claude --print ```bash # On HOST -cat ~/.claude/.claude.json | jq '.projects["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/workspaces/pythontemplate"]' +cat ~/.claude/.claude.json | jq '.projects["/workspaces/"]' ``` Look for: @@ -286,7 +288,7 @@ Contains account info, feature flags, and per-workspace state. Key fields: "userID": "...", "themeMode": "dark", "projects": { - "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/workspaces/pythontemplate": { + "/workspaces/": { "projectOnboardingSeenCount": 999, "hasTrustDialogAccepted": false, ... From d34c0c3683183a9c16e5521545a469986228ddea Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Mon, 24 Aug 2026 18:52:58 +0100 Subject: [PATCH 6/9] fix(rename): substitute the separator-stripped workspace name too devpod builds its workspace name by stripping separators out of the repo name, so this tree carries `pythontemplate` alongside `python_template` -- in ssh hosts and `/workspaces/` paths. rename_project.sh only ever seded `python_template`, so every copy kept the template's workspace name. blooop/bencher is the live proof: its pyproject.toml still runs `ssh pythontemplate.devpod`. The global sed now also replaces `pythontemplate` with the new name minus `_` and `-`, under the same file exclusions. `pythontemplate` is substituted first. sed re-scans each replacement with the later expressions, and a separator-stripped name cannot contain an underscore, so it can never manufacture a fresh `python_template`. The opposite order breaks on a new name that itself contains the stripped string: `pythontemplate_fork` comes out as `pythontemplatefork_fork`. The two TROUBLESHOOTING shell-in commands go back to a literal `devpod ssh `. cefdb48 pointed them at `pixi run dev`, which was the wrong target: of the repos this tree is about to be copied into, only bencher has a `dev` task at all, and bencher's is itself broken. dbw, scratch_env, planthood, rockerc and cargo_rocker have no `dev` task, so `pixi run dev` named something that does not exist almost everywhere. The placeholder matches the style the rest of that commit already used for the .claude.json paths, and Issue 4 now points at `devpod list` / `dl --ls` the way Issue 1 does. --- .devcontainer/claude-code/TROUBLESHOOTING.md | 6 ++++-- scripts/rename_project.sh | 12 +++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.devcontainer/claude-code/TROUBLESHOOTING.md b/.devcontainer/claude-code/TROUBLESHOOTING.md index 1e11b33..59e2156 100644 --- a/.devcontainer/claude-code/TROUBLESHOOTING.md +++ b/.devcontainer/claude-code/TROUBLESHOOTING.md @@ -80,7 +80,7 @@ mv ~/.claude/.claude.json.tmp ~/.claude/.claude.json **Verification:** ```bash # From the host, shell into the container -pixi run dev +devpod ssh claude # Should go straight to interactive mode without wizard ``` @@ -140,7 +140,9 @@ Two different issues: 1. **Verify the mount in the container:** ```bash - pixi run dev + # From the host, shell into the container (`devpod list` shows ``, + # `dl --ls` for workspaces devlaunch created) + devpod ssh mount | grep claude ``` diff --git a/scripts/rename_project.sh b/scripts/rename_project.sh index c1b6590..257c43a 100755 --- a/scripts/rename_project.sh +++ b/scripts/rename_project.sh @@ -8,8 +8,18 @@ mv python_template "$1" ESCAPED_1=$(escape_sed "$1") +# devpod strips separators out of the repo name to build its workspace name, so the tree +# also carries `pythontemplate` (ssh hosts, /workspaces/ paths) which the sed below would +# never match. Substitute the separator-stripped new name for it. +STRIPPED_1="${1//[_-]/}" +ESCAPED_1_STRIPPED=$(escape_sed "$STRIPPED_1") + # change project name in all files (exclude main devcontainer.json to protect template image URL) -find . \( -type d -name .git -prune \) -o \( -type f -not -name 'tasks.json' -not -name 'update_from_template.sh' -not -name 'pixi.lock' -not -path './.devcontainer/devcontainer.json' \) -print0 | xargs -0 sed -i "s/python_template/$ESCAPED_1/g" +# sed re-scans each replacement with the later expressions, so `pythontemplate` goes first: a +# stripped name can never contain an underscore, so it can never manufacture a fresh +# `python_template`. The other order breaks on a new name that itself contains +# `pythontemplate` -- `pythontemplate_fork` would come out as `pythontemplatefork_fork`. +find . \( -type d -name .git -prune \) -o \( -type f -not -name 'tasks.json' -not -name 'update_from_template.sh' -not -name 'pixi.lock' -not -path './.devcontainer/devcontainer.json' \) -print0 | xargs -0 sed -i -e "s/pythontemplate/$ESCAPED_1_STRIPPED/g" -e "s/python_template/$ESCAPED_1/g" # update just the name field in devcontainer.json sed -i "s/\"name\": \"python_template\"/\"name\": \"$ESCAPED_1\"/" .devcontainer/devcontainer.json From 9bb1d62cbd58f78b699b8b90fe0a1e14ae0e844e Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Mon, 24 Aug 2026 18:56:51 +0100 Subject: [PATCH 7/9] fix(rename): stop rewriting the shared devcontainer image URL The prebuilt image reference `ghcr.io/blooop/python_template/devcontainer` appears in four files, but only `.devcontainer/devcontainer.json` was excluded from the repo-wide sed. So renaming a fresh repo rewrote the URL in `README.md`, `.devcontainer/claude-code/README.md`, and `.devcontainer/claude-code/TROUBLESHOOTING.md`, leaving three docs telling you to pull `ghcr.io/blooop//devcontainer` -- a package nobody ever published. Excluding those files wholesale would be wrong: they are full of legitimate `python_template` references that should be renamed. Mask the URL instead, so the exclusion is scoped to the one string that needs it rather than to whole files. --- scripts/rename_project.sh | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/rename_project.sh b/scripts/rename_project.sh index 257c43a..3847d53 100755 --- a/scripts/rename_project.sh +++ b/scripts/rename_project.sh @@ -19,7 +19,12 @@ ESCAPED_1_STRIPPED=$(escape_sed "$STRIPPED_1") # stripped name can never contain an underscore, so it can never manufacture a fresh # `python_template`. The other order breaks on a new name that itself contains # `pythontemplate` -- `pythontemplate_fork` would come out as `pythontemplatefork_fork`. -find . \( -type d -name .git -prune \) -o \( -type f -not -name 'tasks.json' -not -name 'update_from_template.sh' -not -name 'pixi.lock' -not -path './.devcontainer/devcontainer.json' \) -print0 | xargs -0 sed -i -e "s/pythontemplate/$ESCAPED_1_STRIPPED/g" -e "s/python_template/$ESCAPED_1/g" +# The first and last expressions mask the prebuilt image reference: it is the one +# `python_template` in the tree that names a real published package rather than this project, +# so renaming it yields a ghcr.io URL that 404s. It appears in the root README and in +# .devcontainer/claude-code/{README,TROUBLESHOOTING}.md, which must otherwise be renamed +# normally, so the string is protected instead of the files. +find . \( -type d -name .git -prune \) -o \( -type f -not -name 'tasks.json' -not -name 'update_from_template.sh' -not -name 'pixi.lock' -not -path './.devcontainer/devcontainer.json' \) -print0 | xargs -0 sed -i -e 's|blooop/python_template/devcontainer|@@IMGREF@@|g' -e "s/pythontemplate/$ESCAPED_1_STRIPPED/g" -e "s/python_template/$ESCAPED_1/g" -e 's|@@IMGREF@@|blooop/python_template/devcontainer|g' # update just the name field in devcontainer.json sed -i "s/\"name\": \"python_template\"/\"name\": \"$ESCAPED_1\"/" .devcontainer/devcontainer.json From ab018e5c486184587e85923201bbc3d27db0e9c6 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Mon, 24 Aug 2026 19:44:35 +0100 Subject: [PATCH 8/9] fix(devcontainer): restore the gh config mount, which does work The previous two commits removed the ~/.config/gh bind mount and justified it, in devcontainer.json, AGENTS.md and both commit messages, with the claim that the mount never worked because gh keeps its token in the system keyring, so the mounted hosts.yml carries no oauth_token. That claim is false on this host. ~/.config/gh/hosts.yml contains a real oauth_token, and in a running container using the mount, with GH_TOKEN and GITHUB_TOKEN unset, `gh auth status` reports: Logged in to github.com account blooop (/home/vscode/.config/gh/hosts.yml) gh writes to a keyring only when one is available and falls back to the file otherwise, so the keyring claim was true of some environment but not this one, and it was generalised into the file as if it always held. Nothing broke under `dl`, which is why it went unnoticed: `dl` forwards GH_TOKEN, devpod applies workspace env after the devcontainer's own, and the env token wins wherever both are present. The containers that lost gh auth they previously had are the ones opened another way -- a plain `devpod up`, or VS Code's Reopen in Container. devlaunch's own README says to keep the mount for exactly those entry paths. So the mount comes back as the third entry in mounts, the Dockerfile creates /home/vscode/.config/gh for it again, and the comment above the mounts block now says why it is kept rather than why it was dropped. AGENTS.md again states that host gh authentication is shared through the mount, and adds that a forwarded GH_TOKEN outranks it under `dl`. The ~/.ssh removal is untouched and was verified separately: devpod forwards an ssh agent at a socket path of its own, and in a live container `ssh-add -l` lists the host key and `git ls-remote` against a git@github.com: origin succeeds with an empty ~/.ssh. The old hardcoded SSH_AUTH_SOCK=/home/vscode/.ssh/agent.sock overrode that working forwarded socket, so it stays gone too. --- .devcontainer/Dockerfile | 3 +++ .devcontainer/devcontainer.json | 46 +++++++++++++++++++-------------- AGENTS.md | 2 +- 3 files changed, 30 insertions(+), 21 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index ec622e6..b410c74 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -14,3 +14,6 @@ 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/devcontainer.json b/.devcontainer/devcontainer.json index f9a23ac..988d049 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -62,30 +62,36 @@ "XDG_DATA_HOME": "/home/vscode/.local/share" }, - // Two mounts, and the two that are gone were removed for reasons rather - // than tidiness -- both follow the precedent already reasoned out in - // blooop/wayfinder's devcontainer.json. + // Three mounts. The pixi volume keeps the environment out of the bind- + // mounted workspace; the other two share host credentials. // - // ~/.config/gh is gone because it never worked: `gh` keeps its token in the - // system keyring, so the mounted hosts.yml carries no oauth_token and - // `gh auth status` inside the container reports the token as invalid. - // GitHub auth arrives as GH_TOKEN instead, which `dl` forwards into every - // workspace it starts (from GH_TOKEN, GITHUB_TOKEN or `gh auth token`, - // whichever answers first). Opened by something other than `dl` -- a plain - // `devpod up`, or VS Code's Reopen in Container -- this container has no - // `gh` login; export GH_TOKEN yourself for those. + // ~/.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 gone because mounting the directory put entries on the - // developer's real config that nothing outside the container could honour: - // devpod running in here writes `Host .devpod` blocks whose - // ProxyCommand names a binary that exists only inside this container, and - // those outlived the container they pointed at. It also handed over the - // private key, which was never load-bearing -- devpod forwards git - // credentials and can forward an ssh agent, which lends the use of a key - // without copying it. SSH_AUTH_SOCK is gone from containerEnv with it, - // rather than being left as a path nothing fills. + // ~/.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" ], diff --git a/AGENTS.md b/AGENTS.md index e962028..167cf61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,7 @@ This project uses a devcontainer with pixi for environment management. ### Available Tools -- **GitHub CLI (`gh`)**: Available via `pixi run gh` or directly if using a login shell. Authentication arrives as `GH_TOKEN`, which `dl` forwards into every workspace it starts, taking it from `GH_TOKEN`, `GITHUB_TOKEN` or `gh auth token` -- whichever answers first. The container used to mount the host's `~/.config/gh` instead, which never worked: `gh` keeps its token in the system keyring, so the mounted `hosts.yml` carried no `oauth_token`. If the container was opened by something other than `dl` -- a plain `devpod up`, or VS Code's Reopen in Container -- it has no `gh` login and you have to export `GH_TOKEN` yourself. +- **GitHub CLI (`gh`)**: Available via `pixi run gh` or directly if using a login shell. The container mounts the host's `~/.config/gh`, so if you are authenticated on the host, that authentication is shared -- including in a container opened without `dl`, such as a plain `devpod up` or VS Code's Reopen in Container. Under `dl` there is also a forwarded `GH_TOKEN` (taken from `GH_TOKEN`, `GITHUB_TOKEN` or `gh auth token`, whichever answers first), and that takes precedence over the mounted `hosts.yml`. ### Running Commands From c872c83a618548621b49a06d17ce5bf2234e5a02 Mon Sep 17 00:00:00 2001 From: Austin Gregg-Smith Date: Mon, 24 Aug 2026 22:10:07 +0100 Subject: [PATCH 9/9] fix(devcontainer): move postCreate into a script, and stop assuming prek-install Two defects, both found by launching the container rather than by reading the JSON, and both invisible to CI. The inline postCreateCommand ended in `pixi run prek-install`. Three repos in this family define that task; the rest do not, and there `pixi run` exits 127, which fails the lifecycle hook and so fails container creation outright -- the workspace never comes up at all. Syncing this file to descendants therefore broke every descendant that lacks the task. postCreateCommand now calls .devcontainer/post-create.sh, which runs the task only where it exists. The second is a consequence of not mounting ~/.ssh. That decision is still right -- devpod forwards an agent, so the private key does not belong in the container -- but the agent carries authentication, not host identity. On a genuinely fresh container there is no known_hosts, and the first git operation over an ssh remote dies with "Host key verification failed". An interactive user can click through the prompt; a script, an agent or CI inside the container cannot. post-create.sh seeds known_hosts with ssh-keyscan for whatever host origin actually points at, and is non-fatal without network. The comment above mounts claimed `git ls-remote` succeeds from an empty ~/.ssh. It does not, on a fresh container; the container that produced that observation had already accepted github.com's key. Corrected. Moving the body out of the JSON also shrinks the line descendants have to merge, which is the same reasoning that put "name" at the bottom of the file. --- .devcontainer/devcontainer.json | 11 +++++-- .devcontainer/post-create.sh | 54 +++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) create mode 100755 .devcontainer/post-create.sh diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 988d049..7e26bf5 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -81,8 +81,13 @@ // ~/.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. + // key, so the private key does not need to be in here at all. What the + // agent cannot supply is known_hosts: on a genuinely fresh container the + // first ssh to github.com dies with `Host key verification failed`, which + // an interactive user can click through and a script cannot. That is what + // the ssh-keyscan in postCreateCommand below is for -- auth comes from the + // forwarded agent, host identity from keyscan, and neither needs the + // host's ~/.ssh in the container. // 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 @@ -95,7 +100,7 @@ "source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind" ], - "postCreateCommand": "sudo chown vscode .pixi && pixi install && pixi run prek-install", + "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. 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