Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
171 changes: 171 additions & 0 deletions .claude/skills/add-devcontainer-feature/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
---
name: add-devcontainer-feature
description: Scaffold a new DevContainer Feature — manifest, install.sh, README, test — following this repo's conventions
---

Add a new DevContainer Feature to `helpers4/devcontainer`, end to end. Ask the user for the
feature's name and purpose first if it wasn't already specified (kebab-case id, e.g. `foo-dev`).

## 1. Before creating anything

- Check `src/` for an existing feature that already covers this — `ls src/` for the current
list, don't hardcode or guess it, it drifts. Read `AGENTS.md`'s features table for a
one-line summary of each.
- Check whether an official/community feature already does this (e.g.
`ghcr.io/devcontainers/features/*`, `ghcr.io/devcontainers-extra/features/*`). If one exists
and covers the whole need, prefer depending on it (`installsAfter`) over reimplementing —
see `angular-dev`, which delegates CLI install to
`ghcr.io/devcontainers-extra/features/angular-cli` and only adds extensions/settings on top.
Reimplementing something that already exists needs a real justification (e.g. `github-dev`
reimplements `gh` CLI install because no known feature bundles the CLI *and* the IDE
extension together — that bundling is the point of the feature).
- If this is a **dedicated AI-tool feature** (a new IDE assistant, following `claude-dev`/
`mistral-dev`/`copilot-dev`), it must cover all three: CLI install (if one exists), IDE
extension, and settings/configuration — not just one or two.

## 2. Files to create

```text
src/<name>/devcontainer-feature.json
src/<name>/install.sh
src/<name>/README.md
test/<name>/test.sh
```

Use `src/git-absorb/` as a reference for a simple binary-install feature, or `src/claude-dev/`
for one that persists host state via a bind-mount + symlink.

### `devcontainer-feature.json`

- Key order: `id` before `version` (some older features have this backwards — don't copy them).
- Start new features at `"version": "1.0.0"`.
- **Must be plain JSON — no `//` comments.** `devcontainers/action` parses it with
`JSON.parse`, which chokes on JSONC; this broke the release workflow once already (commit
`4e6e5ee`). Put rationale in the README or `install.sh` instead.
- `installsAfter`: at minimum `["ghcr.io/devcontainers/features/common-utils"]`; add
`ghcr.io/helpers4/devcontainer/helpers4-common` or other helpers4 features here if this one
needs them ordered first. Never reference a feature id that isn't in `src/` (or the official
registry) — it's silently ignored if unresolvable, which hides real typos.
- If this feature needs to persist state from the host (credentials, config), read the
"Design constraints for features" section in `AGENTS.md` **before** adding a `mounts` entry:
- A `mounts` source that doesn't exist on the host — file *or* directory — fails the whole
container at start, unconditionally. No feature script can catch this.
- A Feature-level `initializeCommand` **does not work** — verified against the
devcontainers CLI source; it's silently ignored. The only place `initializeCommand` has
effect is the *consumer's* top-level `devcontainer.json`. So: add the `mounts` +
`postStartCommand` to the feature as usual, but the required `mkdir -p`/`touch` line goes
in the feature's own README "Example Usage" as something the consumer must add — see
`claude-dev`'s or `mistral-dev`'s README for the exact pattern to copy.

### `install.sh`

Pattern (copy the bootstrap block verbatim from `src/helpers4-common/install.sh` or any
existing feature — a CI check in `pr-validation.yml`'s `shellcheck` job fails the build if a
copy drifts from the canonical version):

```bash
#!/usr/bin/env bash

# This file is part of helpers4.
# Copyright (C) 2025 baxyz
# SPDX-License-Identifier: LGPL-3.0-or-later
#
# <one-line description of what this installs>

set -euo pipefail

# Bootstrap helpers4 shared library. helpers4-common installs it; if running
# standalone (e.g. devcontainer features test), create it inline so the feature
# is self-contained without a GHCR pull.
if [ ! -f /usr/local/share/helpers4/common.sh ]; then
mkdir -p /usr/local/share/helpers4
cat > /usr/local/share/helpers4/common.sh << 'H4_COMMON'
# shellcheck shell=bash
h4_detect_user() { ... } # copy the exact body from an existing feature
h4_resolve_home() { ... }
h4_apt_update() { ... }
h4_ensure_packages() { ... }
H4_COMMON
fi
# shellcheck source=/dev/null
. /usr/local/share/helpers4/common.sh

if [ "$(id -u)" -ne 0 ]; then
echo 'Script must be run as root.'
exit 1
fi

h4_detect_user
h4_resolve_home

trap cleanup EXIT
cleanup() { rm -rf /tmp/<name>-* ; }

# ... arch detection (x86_64/aarch64), h4_ensure_packages for apt deps,
# download/install to /usr/local/bin/ ...
```

Read the actual bootstrap block out of an existing `install.sh` rather than retyping it from
this skill — copy it byte-for-byte so the CI drift check passes.

### `README.md`

Follow the shape of an existing feature's README: title, one-paragraph description, "Example
Usage" (with the `initializeCommand` snippet if this feature mounts host state), an options
table, and a "How it works" section. If the feature evolves after this, add a "Version
History" section (see `dotfiles-sync`'s) documenting what changed and why per bump — future
maintainers (and future agents) rely on it to avoid re-deriving decisions from scratch.

### `test/<name>/test.sh`

```bash
#!/usr/bin/env bash

# This file is part of helpers4.
# Copyright (C) 2025 baxyz
# SPDX-License-Identifier: LGPL-3.0-or-later

set -e

echo "Testing <name> feature..."
# assert the thing the feature installs is present/working; exit 1 on failure
echo "✅ PASS: ..."
```

Keep it about **build-time artifacts** (binary present, file generated, config written) — it
can't exercise `postStartCommand`/mount-dependent behavior, since `devcontainer features test`
doesn't wire up bind mounts from a real host. If the feature declares `mounts`, add a
"Create mount sources for `<name>`" step to `pr-validation.yml`'s `test-features` job (see the
existing steps for `claude-dev`/`mistral-dev`/`dotfiles-sync`) so the test job's own container
build doesn't fail on a missing source.

## 3. Wire it into the repo

In this order — missing any of these breaks CI or leaves the feature undiscoverable:

1. `scopes.json` → add `<name>` (commit scope validation reads this file automatically; PR CI
fails without it).
2. `.github/workflows/pr-validation.yml` → add `<name>` to the `test-features` job's matrix
(one entry per base image worth testing against), plus a mount-source step if needed.
3. `.github/workflows/test.yml` → add the same matrix entries (this one runs on push to `main`,
not per-PR).
4. `AGENTS.md` → add a row to the features table (`| \`<name>\` | 1.0.0 | <one-line description> |`).

## 4. Verify

```bash
devcontainer features test --features <name> .
```

Then, since this is a **new** feature (not yet published), the version-bump-check in
`pr-validation.yml` won't flag it either way — but confirm the manifest still validates as
plain JSON (`jq empty src/<name>/devcontainer-feature.json`) and that `bash -n
src/<name>/install.sh` / `test/<name>/test.sh` come back clean before moving on. Run
`shellcheck -S warning` on both scripts if it's available locally.

## 5. Commit

Only if explicitly asked to commit *this turn* — per `.dev/AGENTS.md`, commit authorization is
per-turn, not a standing grant. When authorized: `feat(<name>): ✨ add <name> feature` as the
scope (matches `scopes.json` once step 3.1 above is done), following the commit format in
`AGENTS.md` (`<type>(<scope>): <emoji> <description>`).
63 changes: 62 additions & 1 deletion .github/workflows/pr-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,66 @@ jobs:
echo "status=failure" >> $GITHUB_OUTPUT
fi

version-bump-check:
runs-on: ubuntu-latest
outputs:
status: ${{ steps.status.outputs.status }}
steps:
- name: Checkout
uses: actions/checkout@v7
with:
fetch-depth: 0

- name: Check touched features bumped their version
id: check
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
set -euo pipefail

MERGE_BASE=$(git merge-base "${BASE_SHA}" HEAD)
echo "::notice::Diff base = ${MERGE_BASE} → HEAD ($(git rev-parse HEAD))"

mapfile -t names < <(git diff --name-only "${MERGE_BASE}..HEAD" -- src/ \
| awk -F/ '{print $2}' | sort -u)

FAILED=0
for name in "${names[@]}"; do
manifest="src/${name}/devcontainer-feature.json"
[ -f "$manifest" ] || continue # feature removed on this branch

new_version=$(jq -r '.version' "$manifest")
old_version=$(git show "${MERGE_BASE}:${manifest}" 2>/dev/null | jq -r '.version' 2>/dev/null || echo "")

if [ -z "$old_version" ]; then
echo "✅ ${name}: new feature, no bump required"
elif [ "$old_version" = "$new_version" ]; then
echo "❌ ${name}: touched but version unchanged (${new_version})"
FAILED=1
else
echo "✅ ${name}: ${old_version} → ${new_version}"
fi
done

if [ "$FAILED" -eq 1 ]; then
echo ""
echo "One or more touched features didn't bump 'version' in devcontainer-feature.json."
echo "release.yml only publishes a feature whose version changed — bump it (patch by"
echo "default), once for the whole branch. See AGENTS.md 'Modifying an existing"
echo "feature — version bump'."
fi
exit "$FAILED"

- name: Set status
id: status
if: always()
run: |
if [ "${{ job.status }}" == "success" ]; then
echo "status=success" >> $GITHUB_OUTPUT
else
echo "status=failure" >> $GITHUB_OUTPUT
fi

test-features:
runs-on: ubuntu-latest
strategy:
Expand Down Expand Up @@ -175,7 +235,7 @@ jobs:

pr-comment:
runs-on: ubuntu-latest
needs: [conventional-commits, test-features, shellcheck]
needs: [conventional-commits, version-bump-check, test-features, shellcheck]
if: always()
steps:
- name: Update PR comment
Expand All @@ -185,6 +245,7 @@ jobs:
script: |
const jobs = {
'🧾 Conventional Commits': "${{ needs.conventional-commits.outputs.status || 'unknown' }}",
'🔖 Version Bump': "${{ needs.version-bump-check.outputs.status || 'unknown' }}",
'🧪 Feature Tests': "${{ needs.test-features.result || 'unknown' }}",
'🐚 ShellCheck': "${{ needs.shellcheck.outputs.status || 'unknown' }}",
};
Expand Down
79 changes: 73 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,17 +32,17 @@ devcontainer features test .
| `github-dev` | 1.0.3 | gh CLI, Copilot Chat, PR/Issues/Actions extensions |
| `copilot-dev` | 1.0.1 | Copilot Chat + AI instructions (commits, PRs, code review) |
| `claude-dev` | 1.0.4 | Claude Code extension + `~/.claude` bind-mount (credentials + memory persist) |
| `mistral-dev` | 1.0.1 | Mistral Vibe extension + `~/.vibe` bind-mount |
| `mistral-dev` | 1.0.2 | Mistral Vibe extension + `~/.vibe` bind-mount |
| `typescript-dev` | 1.0.5 | TS/JS dev, import management (dependsOn essential-dev) |
| `angular-dev` | 1.0.2 | Angular dev, port 4200 |
| `angular-dev` | 1.0.6 | Angular dev, port 4200 |
| `vite-plus` | 1.0.3 | vp CLI, Oxlint/Oxfmt, Vitest |
| `package-auto-install` | | Auto-detect and install packages |
| `package-auto-install` | 1.0.7 | Auto-detect and install packages |
| `pnpm-store` | 1.0.4 | Shared pnpm store via Docker named volume (dependsOn helpers4-common) |
| `auto-header` | — | LGPL-3.0 license headers |
| `git-absorb` | 1.0.2 | git-absorb from GitHub releases |
| `dotfiles-sync` | 1.0.2 | Sync Git/SSH/GPG/npm/gh config from host |
| `git-absorb` | 1.0.7 | git-absorb from GitHub releases |
| `dotfiles-sync` | 1.0.7 | Sync Git/SSH/GPG/npm/gh config from host |
| `peon-ping` | 1.0.3 | AI agent sound notifications |
| `shell-history-per-project` | 1.0.2 | Persistent shell history (zsh/bash/fish) |
| `shell-history-per-project` | 1.0.7 | Persistent shell history (zsh/bash/fish) |

**Adding a new feature — checklist:**

Expand All @@ -52,10 +52,77 @@ devcontainer features test .
4. `.github/workflows/pr-validation.yml` + `test.yml` → add to test matrix
5. This `AGENTS.md` features table

**Modifying an existing feature — version bump:**

Any change under `src/<name>/` (`install.sh`, `devcontainer-feature.json`,
`README.md`, …) must bump that feature's `version` field (patch by default,
minor/major when warranted) — `release.yml` only tags and publishes a feature
whose `version` changed between the base branch and HEAD, so an unbumped
feature change silently never ships. Bump it **once per branch**: if the
version on the branch already differs from `main`'s, a further commit on that
same branch/PR must *not* bump it again — check the diff against `main`
first, don't bump reflexively on every commit.

Enforced by the `version-bump-check` job in `pr-validation.yml`: it fails the
PR if a touched feature's `version` is unchanged from `main`. It's a blocking
check only — it never commits a bump on your behalf (deliberately: no bot
commits, no push-permission/fork edge cases, consistent with how
`conventional-commits` already works in this repo). Bump the version yourself
and push again.

**License header (all scripts):**

```bash
# This file is part of helpers4.
# Copyright (C) 2025 baxyz
# SPDX-License-Identifier: LGPL-3.0-or-later
```

## Design constraints for features

These are hard requirements, not style preferences — violating them breaks the
container for users, sometimes silently.

- **A `mounts` entry fails the whole container if the host source doesn't
exist — file *or* directory, no exceptions.** Verified against
`@devcontainers/cli` directly (decompiled `devContainersSpecCLI.js`):
`docker run --mount type=bind,...` is used, which errors out
(`bind source path does not exist`) instead of silently creating anything.
No feature script can catch this — mounts resolve before `install.sh` /
`postCreateCommand` / `postStartCommand` ever run.
- **A Feature's own `initializeCommand` cannot fix this — it's silently
ignored.** Also verified in the CLI source: the lifecycle-command merge
step only collects `onCreateCommand`, `updateContentCommand`,
`postCreateCommand`, `postStartCommand`, `postAttachCommand` from each
Feature (`lv` in the minified bundle) plus `mounts`/`customizations`/etc.
(`xV`) — `initializeCommand` is *not* in either list. It only has effect in
the **consumer's own top-level `devcontainer.json`**. This was tried on
`claude-dev` (v1.0.3, since reverted as dead code) before the real fix
landed: document a required `initializeCommand` in the feature's README
Example Usage (see `claude-dev`'s and `mistral-dev`'s READMEs) — a Feature
cannot inject into the consumer's `devcontainer.json`, so this is the only
place it can actually run.
- **"Out-of-the-box" for a mount-dependent feature means "one documented
`initializeCommand` line away," not zero-config.** Given the constraint
above, don't chase a fully silent fix for a feature with a hard mount
dependency — document the required line prominently instead. If a feature
can tolerate a missing/empty source at the file level (many small files,
like `dotfiles-sync`), prefer staging into `/mnt/h4dotfiles`-style path and
merging at `postStartCommand` over a hard mount dependency — but note that
even staged mounts still fail hard if *their* source is missing, so staging
only helps once the source is guaranteed to exist (see `dotfiles-sync`'s
own `initializeCommand` requirement for exactly the files it can't avoid
mounting).
- **`devcontainer-feature.json` must be plain JSON — no `//` comments.**
`devcontainers/action` parses it with `JSON.parse`, which chokes on JSONC.
This broke the release workflow once already (see commit `4e6e5ee`). Put
rationale in the README or a neighboring `.sh` file instead.
- **Must work inside a VS Code multi-root `.code-workspace`** — this repo's own
devcontainer bundles 6 sibling repos this way (see the root `.dev/CLAUDE.md`
workspace layout). Treat this as a standard case, not an edge case.
- **Dedicated AI-tool features must cover three things**: CLI install, IDE
extension, and settings/configuration — see `claude-dev`, `mistral-dev`,
`copilot-dev`. `github-dev` intentionally reimplements `gh` CLI install
rather than depending on an upstream feature, because no known existing
devcontainer feature bundles the CLI *and* the IDE extension together — that
bundling is the actual reason this feature exists.
Loading
Loading