Create a new devcontainer feature end-to-end: source files, test, README update, and executable permissions — ready for CI.
Use this skill when an issue requests adding a new CLI tool or binary as a devcontainer feature.
The issue description typically provides:
- Feature name (used to derive the directory name / feature
id) - Project URL (GitHub repo or homepage)
- Releases URL (GitHub Releases page)
- Install method (if non-default)
- Any special notes (e.g., extra config, service setup)
Derive the feature directory name (which is also the id in devcontainer-feature.json).
Priority order:
- Domain-style — preferred when the project has a well-known website (e.g.,
starship.rs,chezmoi.io,deno.com,atuin.sh) - Owner-project — when the project name alone is ambiguous (e.g.,
charmbracelet-gum,schpet-linear-cli). Use hyphens, not slashes. - Plain name — fallback for widely recognized tools (e.g.,
bat,jq,fzf)
The issue usually specifies or strongly implies the name. Use it as given unless it violates these conventions.
Unless the issue specifies otherwise, choose the install method in this priority order:
gh release— project publishes pre-built Linux binaries on GitHub Releases (most common)curl— project provides an official install script but no GitHub release binariesapt— package is available in Debian/Ubuntu repos with no better option- Last resort —
cargo,bun,npm,nix(preferbun install -governpm install -g)
Before writing install.sh, inspect the project's GitHub Releases page to determine:
- Asset naming pattern — the exact filename template (e.g.,
fzf-${version}-linux_${architecture}.tar.gz) - Archive format —
.tar.gz,.zip, or standalone binary - Directory nesting — is the binary at the archive root (strip=0) or inside a directory (strip=1+)?
- Architecture labels — how the project names architectures (x86_64 vs amd64 vs x86-64, aarch64 vs arm64, etc.)
- Tag format —
v1.0.0vs1.0.0vs other prefix patterns - Linux target triple — some use
unknown-linux-musl, some uselinux, some useLinux
Map Debian architectures to the project's labels in debian_get_target_arch():
amd64→ (varies:x86_64,amd64,x86-64)arm64→ (varies:aarch64,arm64)armhf→ (varies:arm,armv6,armv7)i386→ (varies:i686,x86,386)
If web access to the GitHub Releases page is blocked or unavailable:
- Check the issue description — it should include a releases URL and may describe the asset naming pattern
- Use the GitHub API —
curl -s https://api.github.com/repos/OWNER/REPO/releases/latestreturns JSON with all asset names listed underassets[].name - Follow common conventions — most Go projects use
<name>_<version>_linux_<arch>.tar.gz, most Rust projects use<name>-v<version>-<arch>-unknown-linux-musl.tar.gz - Ask the user — if none of the above work, request the exact asset naming pattern, archive structure, and architecture labels
Never guess the asset naming pattern. If you cannot verify it, ask.
Create these files:
{
"name": "<feature-display-name>",
"id": "<FEATURE_ID>",
"version": "1.0.0",
"description": "Install \"<binary-name>\" binary",
"documentationURL": "https://github.com/devcontainer-community/devcontainer-features/tree/main/src/<FEATURE_ID>",
"options": {
"version": {
"type": "string",
"default": "latest",
"proposals": [
"latest"
],
"description": "Version of \"<binary-name>\" to install."
}
},
"installsAfter": [
"ghcr.io/devcontainer-community/features/ca-certificates:latest"
]
}Notes:
nameuses the owner/project format with a slash (e.g.,"schpet/linear-cli","github.com/cli","cloudflare.com/warp-cli"). For plain-name features (e.g.,bat,jq),nameis just the tool name.idis derived fromnameby replacing/with-(e.g.,"schpet/linear-cli"→"schpet-linear-cli"). It MUST match the directory name exactly.versionstarts at"1.0.0"for new features- Always include
"installsAfter": ["ghcr.io/devcontainer-community/features/ca-certificates:latest"]so CA certificates are available before the feature runs
Use src/fzf/install.sh as the canonical template for gh release features.
Copy helper functions verbatim — ideally sourced from https://github.com/devcontainer-community/shell-snippets. The functions to include:
apt_get_update,apt_get_checkinstall,apt_get_cleanupcheck_curl_envsubst_file_tar_installedcurl_check_url,curl_download_stdout,curl_download_untardebian_get_arch,debian_get_target_archecho_bannergithub_list_releases,github_get_latest_release,github_get_tag_for_versionutils_check_version
Only customize these parts:
readonly githubRepository='owner/repo'readonly binaryName='...'readonly versionArgument='--version'debian_get_target_arch()case mappings (per Step 3)downloadUrlTemplate(exact asset naming pattern from Step 3)binaryPathInArchive(path inside the archive, from Step 3)
Required header (all install.sh files):
#!/bin/bash
set -o errexit
set -o pipefail
set -o noclobber
set -o nounset
set -o allexportRequired footer (all install.sh files):
echo_banner "devcontainer.community"
echo "Installing $name..."
install "$@"
echo "(*) Done!"#!/bin/bash
set -o errexit
set -o pipefail
set -o noclobber
set -o nounset
set -o allexport
readonly name="<PACKAGE_NAME>"
apt_get_update() {
if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then
echo "Running apt-get update..."
apt-get update -y
fi
}
apt_get_checkinstall() {
if ! dpkg -s "$@" >/dev/null 2>&1; then
apt_get_update
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends --no-install-suggests --option 'Debug::pkgProblemResolver=true' --option 'Debug::pkgAcquire::Worker=1' "$@"
fi
}
apt_get_cleanup() {
apt-get clean
rm -rf /var/lib/apt/lists/*
}
echo_banner() {
local text="$1"
echo -e "\e[1m\e[97m\e[41m$text\e[0m"
}
install() {
apt_get_checkinstall <PACKAGE_NAME>
apt_get_cleanup
}
echo_banner "devcontainer.community"
echo "Installing $name..."
install "$@"
echo "(*) Done!"#!/bin/bash
set -o errexit
set -o pipefail
set -o noclobber
set -o nounset
set -o allexport
readonly name="<TOOL_NAME>"
apt_get_update() {
if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then
echo "Running apt-get update..."
apt-get update -y
fi
}
apt_get_checkinstall() {
if ! dpkg -s "$@" >/dev/null 2>&1; then
apt_get_update
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends --no-install-suggests --option 'Debug::pkgProblemResolver=true' --option 'Debug::pkgAcquire::Worker=1' "$@"
fi
}
apt_get_cleanup() {
apt-get clean
rm -rf /var/lib/apt/lists/*
}
echo_banner() {
local text="$1"
echo -e "\e[1m\e[97m\e[41m$text\e[0m"
}
install() {
apt_get_checkinstall curl ca-certificates # add unzip if needed
# For user-level install (installs to $HOME): use su $_REMOTE_USER -c "..."
# For system-level install (installs to /usr/local): run directly
su $_REMOTE_USER -c "curl -fsSL <INSTALL_SCRIPT_URL> | bash"
apt_get_cleanup
}
echo_banner "devcontainer.community"
echo "Installing $name..."
install "$@"
echo "(*) Done!"Notes for curl method:
- Use
su $_REMOTE_USER -c "..."when the tool installs to the user's HOME directory - Run directly (no
su) when the tool installs to a system path like/usr/local - If VERSION is supported, check for
latestand resolve it, then pass to the install script (seesrc/deno.com/install.shfor an example)
#!/bin/bash
set -o errexit
set -o pipefail
set -o noclobber
set -o nounset
set -o allexport
readonly cratesPackage='<CRATE_NAME>'
readonly binaryName='<BINARY_NAME>'
readonly binaryTargetFolder='/usr/local/bin'
readonly name='<DISPLAY_NAME>'
apt_get_update() {
if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then
echo "Running apt-get update..."
apt-get update -y
fi
}
apt_get_checkinstall() {
if ! dpkg -s "$@" >/dev/null 2>&1; then
apt_get_update
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends --no-install-suggests --option 'Debug::pkgProblemResolver=true' --option 'Debug::pkgAcquire::Worker=1' "$@"
fi
}
apt_get_cleanup() {
apt-get clean
rm -rf /var/lib/apt/lists/*
}
echo_banner() {
local text="$1"
echo -e "\e[1m\e[97m\e[41m$text\e[0m"
}
utils_check_version() {
local version=$1
if ! [[ "${version:-}" =~ ^(latest|[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
printf >&2 '=== [ERROR] Option "version" (value: "%s") is not "latest" or valid semantic version format "X.Y.Z" !\n' \
"$version"
exit 1
fi
}
install() {
utils_check_version "$VERSION"
apt_get_checkinstall curl ca-certificates build-essential
export RUSTUP_HOME=/usr/local/rustup
export CARGO_HOME=/usr/local/cargo
if ! command -v cargo >/dev/null 2>&1; then
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | \
sh -s -- -y --no-modify-path --default-toolchain stable
fi
export PATH=/usr/local/cargo/bin:$PATH
if [ "$VERSION" == 'latest' ] || [ -z "$VERSION" ]; then
cargo install "$cratesPackage"
else
cargo install "$cratesPackage" --version "$VERSION"
fi
readonly binaryTargetPath="${binaryTargetFolder}/${binaryName}"
ln -sf /usr/local/cargo/bin/"$binaryName" "$binaryTargetPath"
chmod 755 "$binaryTargetPath"
apt_get_cleanup
}
echo_banner "devcontainer.community"
echo "Installing $name..."
install "$@"
echo "(*) Done!"Prefer bun install -g over npm install -g when both are viable.
#!/bin/bash
set -o errexit
set -o pipefail
set -o noclobber
set -o nounset
set -o allexport
readonly binaryName='<BINARY_NAME>'
readonly binaryTargetFolder='/usr/local/bin'
apt_get_update() {
if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then
echo "Running apt-get update..."
apt-get update -y
fi
}
apt_get_checkinstall() {
if ! dpkg -s "$@" >/dev/null 2>&1; then
apt_get_update
DEBIAN_FRONTEND=noninteractive apt-get -y install --no-install-recommends --no-install-suggests --option 'Debug::pkgProblemResolver=true' --option 'Debug::pkgAcquire::Worker=1' "$@"
fi
}
apt_get_cleanup() {
apt-get clean
rm -rf /var/lib/apt/lists/*
}
echo_banner() {
local text="$1"
echo -e "\e[1m\e[97m\e[41m$text\e[0m"
}
utils_check_version() {
local version=$1
if ! [[ "${version:-}" =~ ^(latest|[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
printf >&2 '=== [ERROR] Option "version" (value: "%s") is not "latest" or valid semantic version format "X.Y.Z" !\n' \
"$version"
exit 1
fi
}
bun_ensure_installed() {
if ! command -v bun >/dev/null 2>&1; then
echo "Bun is not installed. Installing bun to /usr/local..."
apt_get_checkinstall unzip curl ca-certificates
export BUN_INSTALL=/usr/local
curl -fsSL https://bun.sh/install | bash
fi
}
install() {
utils_check_version "$VERSION"
export BUN_INSTALL=/usr/local
bun_ensure_installed
if [ "$VERSION" == 'latest' ] || [ -z "$VERSION" ]; then
bun install -g <NPM_PACKAGE_NAME>
else
bun install -g "<NPM_PACKAGE_NAME>@${VERSION}"
fi
apt_get_cleanup
}
echo_banner "devcontainer.community"
echo "Installing $binaryName..."
install "$@"
echo "(*) Done!"All helper functions should be copied verbatim. The canonical source is https://github.com/devcontainer-community/shell-snippets — check there first for the latest versions of shared functions.
# <display-name>
## Project
- [<project-name>](<project-url>)
## Description
<2-3 sentence description of what the tool does and its main use case. Use backticks around the CLI command name.>
## Installation Method
<One of these templates:>
- gh release: "Downloaded as a pre-compiled binary from the [GitHub releases page](<releases-url>) and placed in `/usr/local/bin`."
- apt: "Installed via the system APT package manager (`apt-get install <name>`)."
- curl: "Installed via the official install script."
- cargo/bun/npm: "Installed via `<package-manager> install <package>`."
## Other Notes
_No additional notes._Add real notes under "Other Notes" only if there are genuinely important caveats (e.g., requires specific env vars, requires a running service, user-level vs system-level install).
Do NOT create this file manually. It is auto-generated by the release workflow from devcontainer-feature.json + NOTES.md.
#!/bin/bash
set -e
# Optional: Import test library bundled with the devcontainer CLI
# See https://github.com/devcontainers/cli/blob/HEAD/docs/features/test.md#dev-container-features-test-lib
# Provides the 'check' and 'reportResults' commands.
source dev-container-features-test-lib
# Feature-specific tests
check "execute command" bash -c "<binary-name> --version | grep '<expected-grep-pattern>'"
# Report results
reportResultsChoosing the grep pattern:
- Run the binary's
--version(orversion) command to see its actual output format - Grep for a stable substring (usually the binary name or
version) - If
--versionis not supported, use whichever flag produces identifiable output - For services (like sshd), test that the service binary exists and test relevant functionality
Do NOT modify this file. The CI workflow automatically detects new features via src/ and test/ directory changes.
Add a new row to the feature table in README.md (repo root). Maintain alphabetical order.
Format:
| [<display-name>](https://github.com/devcontainer-community/devcontainer-features/tree/main/src/<FEATURE_ID>) | `<binary-name>` — <short description> | <install-method> | 1.0.0 |
Where <install-method> is one of: gh release, apt, curl, cargo, bun, npm, nix.
The install.sh file MUST have the executable bit set. Run both:
chmod +x src/<FEATURE_ID>/install.sh
git update-index --chmod=+x src/<FEATURE_ID>/install.sh- Verify
devcontainer-feature.jsonis valid JSON and matches the schema - Verify
install.shhas correct shebang, set -o flags, and the executable bit - Verify
test.shsourcesdev-container-features-test-liband callsreportResults - Verify the README.md table entry is in the correct alphabetical position
- Run CI tests via GitHub Actions — the test workflow will automatically pick up the new feature on a PR:
devcontainer features test --skip-scenarios -f <FEATURE_ID> -i debian:latest .devcontainer features test --skip-scenarios -f <FEATURE_ID> -i ubuntu:latest .devcontainer features test --skip-scenarios -f <FEATURE_ID> -i mcr.microsoft.com/devcontainers/base:ubuntu .
-
src/<ID>/devcontainer-feature.json— valid JSON, correctid, version1.0.0 -
src/<ID>/install.sh— working script, executable bit set, helper functions verbatim from template -
src/<ID>/NOTES.md— links to project, description, install method documented -
test/<ID>/test.sh— sources test lib, has at least onecheck, callsreportResults -
README.md— new row added in alphabetical order -
src/<ID>/README.md— NOT manually created (auto-generated) - Executable bit set via
chmod +xANDgit update-index --chmod=+x - CI tests pass on all three base images (debian, ubuntu, devcontainers/base)