diff --git a/.github/workflows/validate.yaml b/.github/workflows/validate.yaml new file mode 100644 index 0000000..7aa0af5 --- /dev/null +++ b/.github/workflows/validate.yaml @@ -0,0 +1,13 @@ +name: Validate Dev Container Features + +on: + pull_request: + workflow_dispatch: + +jobs: + validate: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Validate metadata and shell script syntax + run: bash test/validate.sh diff --git a/README.md b/README.md index 25aa7d1..5defed7 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ agent-specific repository. It provides the following features: - Roo Code - Playwright Agent CLI - GitLab CLI +- tmux +- Vim **Claude Code** has an [official Dev Container Feature](https://github.com/anthropics/devcontainer-features). This repository provides a companion Feature that adds persistence on top of the official installation. @@ -35,7 +37,9 @@ This keeps the setting out of each project's `.devcontainer/devcontainer.json`. "ghcr.io/temeteke/devcontainer-features/cline:0": {}, "ghcr.io/temeteke/devcontainer-features/roo-code:0": {}, "ghcr.io/temeteke/devcontainer-features/playwright:0": {}, - "ghcr.io/temeteke/devcontainer-features/gitlab-cli:0": {} + "ghcr.io/temeteke/devcontainer-features/gitlab-cli:0": {}, + "ghcr.io/temeteke/devcontainer-features/tmux:0": {}, + "ghcr.io/temeteke/devcontainer-features/vim:0": {} } } ``` @@ -87,6 +91,8 @@ other sensitive data. Since they are stored directly in your host home directory | `playwright` | AI agent tool: Playwright Agent CLI and browser runtime only | none | | `gitlab-cli` | Coding tool: `glab` CLI from GitLab releases | `~/.config/glab-cli` | | `agents` | Shared feature for agent skills/state only | `~/.agents` | +| `tmux` | `tmux` via apt (Debian/Ubuntu) | read-only `~/.tmux.conf` | +| `vim` | `vim` via apt (Debian/Ubuntu) | read-only `~/.vimrc`, read-write `~/.vim` | ## Publishing @@ -99,3 +105,22 @@ fork the repository. This repository is an unofficial collection of Dev Container Features. It is not affiliated with, endorsed by, or maintained by OpenAI, Anthropic, Roo Code, Cline, OpenCode, or GitLab. + +## Terminal Features + +The `tmux` Feature installs tmux and shares the host's `~/.tmux.conf` +file read-only. The `vim` Feature installs Vim and shares `~/.vimrc` +read-only and `~/.vim/` read-write. This supports standard dotfile layouts, +including Vim configurations that install plugins or write undo data under +`~/.vim/`. + +Before starting a Dev Container, ensure the corresponding host paths exist. +A missing bind-mount source may prevent the container from starting. +These Features do not mount the entire host HOME directory. + +The writable `~/.vim/` mount allows processes in the container to modify +host Vim configuration, plugins and state. Review this security tradeoff +before using these Features with untrusted code. + +See [`src/tmux/README.md`](src/tmux/README.md) and +[`src/vim/README.md`](src/vim/README.md) for details. diff --git a/src/tmux/README.md b/src/tmux/README.md new file mode 100644 index 0000000..774a48c --- /dev/null +++ b/src/tmux/README.md @@ -0,0 +1,28 @@ +# tmux Feature + +Installs tmux on Debian/Ubuntu and exposes the **host** `~/.tmux.conf` +file in the container, read-only. A symlink at the container user's +`~/.tmux.conf` points to the mounted file. + +## Host configuration + +The Feature uses tmux's conventional `~/.tmux.conf` configuration file. +It does not mount `~/.config/tmux/` or other host configuration paths. + +Before building/starting the Dev Container, make sure `~/.tmux.conf` +is a regular file or a resolvable symlink to a file on the host. A missing +file bind mount cannot be skipped dynamically; the container may fail to start. + +## Usage + +```json +{ + "features": { + "ghcr.io/temeteke/devcontainer-features/tmux:0": {} + } +} +``` + +Only the configuration file is shared; tmux server/socket/session state +belongs to the temporary container. The container startup script refuses +to overwrite an existing `~/.tmux.conf` file or unrelated symlink. diff --git a/src/tmux/devcontainer-feature.json b/src/tmux/devcontainer-feature.json new file mode 100644 index 0000000..987327b --- /dev/null +++ b/src/tmux/devcontainer-feature.json @@ -0,0 +1,15 @@ +{ + "id": "tmux", + "version": "0.1.0", + "name": "tmux", + "description": "Installs tmux and shares the host ~/.tmux.conf read-only.", + "mounts": [ + { + "type": "bind", + "source": "${localEnv:HOME}/.tmux.conf", + "target": "/mnt/devcontainer-features/tmux/tmux.conf", + "readonly": true + } + ], + "postCreateCommand": "/usr/local/share/devcontainer-features/tmux/setup-persistence.sh" +} diff --git a/src/tmux/install.sh b/src/tmux/install.sh new file mode 100755 index 0000000..47346db --- /dev/null +++ b/src/tmux/install.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v apt-get >/dev/null 2>&1; then + echo "[tmux] Debian/Ubuntu (apt-get) is required." >&2 + exit 1 +fi + +export DEBIAN_FRONTEND=noninteractive +apt-get update +apt-get install -y --no-install-recommends tmux +rm -rf /var/lib/apt/lists/* + +mkdir -p "/usr/local/share/devcontainer-features/tmux" +install -m 0755 "${0%/*}/setup-persistence.sh" "/usr/local/share/devcontainer-features/tmux/setup-persistence.sh" diff --git a/src/tmux/setup-persistence.sh b/src/tmux/setup-persistence.sh new file mode 100755 index 0000000..f00eae9 --- /dev/null +++ b/src/tmux/setup-persistence.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail + +SOURCE="/mnt/devcontainer-features/tmux/tmux.conf" +: "${HOME:?HOME must be set}" +TARGET="$HOME/.tmux.conf" + +if [[ ! -f "$SOURCE" ]]; then + echo "[tmux] Host ~/.tmux.conf mount is missing or is not a file: $SOURCE" >&2 + exit 1 +fi + +if [[ -L "$TARGET" ]]; then + if [[ "$(readlink "$TARGET")" == "$SOURCE" ]]; then + exit 0 + fi + echo "[tmux] Refusing to replace existing symlink: $TARGET" >&2 + exit 1 +elif [[ -e "$TARGET" ]]; then + echo "[tmux] Refusing to overwrite existing container file: $TARGET" >&2 + exit 1 +fi + +ln -s "$SOURCE" "$TARGET" +echo "[tmux] Linked $TARGET -> $SOURCE" diff --git a/src/vim/README.md b/src/vim/README.md new file mode 100644 index 0000000..546c5d1 --- /dev/null +++ b/src/vim/README.md @@ -0,0 +1,37 @@ +# Vim Feature + +Installs the full `vim` package on Debian/Ubuntu. Exposes the **host** +`~/.vimrc` **read-only** and `~/.vim/` **read-write** and links both +paths into the container user's HOME. + +## Host configuration + +The Feature uses Vim's conventional `~/.vimrc` and `~/.vim/` paths. +Configurations that use different locations must arrange those paths on +the host before starting the container. + +Make sure both `~/.vimrc` (file) and `~/.vim/` (directory) exist on the +host before starting a container. Missing bind sources may prevent startup. + +**Why is `~/.vim` writable?** Some Vim configurations install or update +plugins and store undo or other runtime data inside `~/.vim/`. A read-only +mount would prevent these operations. This also means processes running +inside the container can modify host Vim configuration, plugins and state. +Review this exception to workspace isolation before using the Feature +with untrusted code. + +The optional `~/.vimrc.local` is not mounted, so Vim skips it unless +another mount provides it. + +## Usage + +```json +{ + "features": { + "ghcr.io/temeteke/devcontainer-features/vim:0": {} + } +} +``` + +The setup script refuses to overwrite existing nonempty container-side +configuration. Use a base image with an empty/default Vim HOME. diff --git a/src/vim/devcontainer-feature.json b/src/vim/devcontainer-feature.json new file mode 100644 index 0000000..51bd734 --- /dev/null +++ b/src/vim/devcontainer-feature.json @@ -0,0 +1,21 @@ +{ + "id": "vim", + "version": "0.1.0", + "name": "Vim", + "description": "Installs Vim and shares host ~/.vimrc (read-only) and ~/.vim (read-write).", + "mounts": [ + { + "type": "bind", + "source": "${localEnv:HOME}/.vimrc", + "target": "/mnt/devcontainer-features/vim/vimrc", + "readonly": true + }, + { + "type": "bind", + "source": "${localEnv:HOME}/.vim", + "target": "/mnt/devcontainer-features/vim/vim", + "readonly": false + } + ], + "postCreateCommand": "/usr/local/share/devcontainer-features/vim/setup-persistence.sh" +} diff --git a/src/vim/install.sh b/src/vim/install.sh new file mode 100755 index 0000000..9a3657e --- /dev/null +++ b/src/vim/install.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v apt-get >/dev/null 2>&1; then + echo "[vim] Debian/Ubuntu (apt-get) is required." >&2 + exit 1 +fi + +export DEBIAN_FRONTEND=noninteractive +apt-get update +apt-get install -y --no-install-recommends vim +rm -rf /var/lib/apt/lists/* + +mkdir -p "/usr/local/share/devcontainer-features/vim" +install -m 0755 "${0%/*}/setup-persistence.sh" "/usr/local/share/devcontainer-features/vim/setup-persistence.sh" diff --git a/src/vim/setup-persistence.sh b/src/vim/setup-persistence.sh new file mode 100755 index 0000000..17124be --- /dev/null +++ b/src/vim/setup-persistence.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONFIG_FILE="/mnt/devcontainer-features/vim/vimrc" +CONFIG_DIR="/mnt/devcontainer-features/vim/vim" +: "${HOME:?HOME must be set}" + +if [[ ! -f "$CONFIG_FILE" ]]; then + echo "[vim] Host ~/.vimrc mount is missing or is not a file: $CONFIG_FILE" >&2 + exit 1 +fi +if [[ ! -d "$CONFIG_DIR" ]]; then + echo "[vim] Host ~/.vim mount is missing or is not a directory: $CONFIG_DIR" >&2 + exit 1 +fi + +link_config() { + local source="$1" target="$2" kind="$3" + + if [[ -L "$target" ]]; then + if [[ "$(readlink "$target")" == "$source" ]]; then + return 0 + fi + echo "[vim] Refusing to replace existing symlink: $target" >&2 + return 1 + fi + + if [[ "$kind" == dir && -d "$target" ]]; then + if [[ -n "$(ls -A "$target")" ]]; then + echo "[vim] Refusing to replace nonempty directory: $target" >&2 + return 1 + fi + rmdir "$target" + elif [[ -e "$target" ]]; then + echo "[vim] Refusing to overwrite existing container path: $target" >&2 + return 1 + fi + + ln -s "$source" "$target" + echo "[vim] Linked $target -> $source" +} + +# Check both destinations before modifying either to avoid partial migration. +for dest in "$HOME/.vimrc" "$HOME/.vim"; do + case "$dest" in + "$HOME/.vimrc") src="$CONFIG_FILE"; kind=file ;; + "$HOME/.vim") src="$CONFIG_DIR"; kind=dir ;; + esac + if [[ -L "$dest" && "$(readlink "$dest")" != "$src" ]] || + [[ ! -L "$dest" && -e "$dest" && + ( "$kind" == file || ! -d "$dest" || -n "$(ls -A "$dest")" ) ]]; then + echo "[vim] Refusing to replace pre-existing container configuration: $dest" >&2 + exit 1 + fi +done + +link_config "$CONFIG_FILE" "$HOME/.vimrc" file +link_config "$CONFIG_DIR" "$HOME/.vim" dir diff --git a/test/validate.sh b/test/validate.sh old mode 100644 new mode 100755