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
22 changes: 22 additions & 0 deletions .github/workflows/validate.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
name: Validate Dev Container Features

on:
pull_request:
paths:
- "src/**"
- "test/**"
- ".github/workflows/validate.yaml"
push:
branches: [main]
paths:
- "src/**"
- "test/**"
- ".github/workflows/validate.yaml"

jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate JSON and shell syntax
run: bash test/validate.sh
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ agent-specific repository. It provides the following features:
- Claude Code
- Codex
- OpenCode
- Pi Coding Agent
- Cline
- Roo Code
- Playwright Agent CLI
Expand Down Expand Up @@ -38,6 +39,7 @@ Add Features to `.devcontainer/devcontainer.json`:
{
"features": {
"ghcr.io/temeteke/devcontainer-features/codex:0": {},
"ghcr.io/temeteke/devcontainer-features/pi:0": {},
"ghcr.io/temeteke/devcontainer-features/github-cli:0": {},
"ghcr.io/temeteke/devcontainer-features/gitlab-cli:0": {
"version": "1.126.0"
Expand Down Expand Up @@ -135,6 +137,7 @@ Features only for projects you trust.
| `claude-code` | Coding tool via official Feature | `~/.claude` |
| `codex` | Coding tool: `@openai/codex` via npm, VS Code extension `openai.chatgpt` | `~/.codex` |
| `opencode` | Coding tool: `@opencode/cli` (OpenCode 2) via npm, VS Code extension `sst-dev.opencode` | `~/.config/opencode`, `~/.local/share/opencode`, `~/.cache/opencode/npm` via `devcontainer-opencode-npm-cache` volume, `~/.agents` via `agents` |
| `pi` | Coding tool: `@earendil-works/pi-coding-agent` via npm | `~/.pi/agent` |
| `cline` | Coding tool: VS Code extension `saoudrizwan.claude-dev` | `~/Documents/Cline`, `~/.cline` |
| `roo-code` | Coding tool: VS Code extension `rooveterinaryinc.roo-cline` | `~/.roo`, VS Code globalStorage |
| `playwright` | AI agent tool: Playwright Agent CLI and browser runtime only | none |
Expand Down
44 changes: 44 additions & 0 deletions src/pi/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Pi Coding Agent Feature

Installs the [Pi Coding Agent](https://pi.dev/) CLI with npm (command: `pi`).

## Options

- `install`: default `true`
- `persist`: default `true`
- `version`: npm version for `@earendil-works/pi-coding-agent`, default `latest`

## Persistence

The default `~/.pi/agent` directory is bind-mounted from the host and linked into
the container's user home. This includes authentication, settings, installed
extensions, and sessions. Project-local `.pi/` files remain in the workspace.
The feature uses the default agent directory; custom `PI_CODING_AGENT_DIR`
locations are not automatically mounted.

**Before creating the container**, run `mkdir -p ~/.pi/agent` on the
Docker/Dev Container host so the bind-mount source exists. Data is writable
from within the container and survives deletion of a disposable container.

The feature depends on the upstream Node.js feature (LTS). It uses the
upstream-recommended `npm install -g --ignore-scripts` form.

## Usage

Add this Feature to your `devcontainer.json`:

```json
{
"features": {
"ghcr.io/temeteke/devcontainer-features/pi:0": {}
}
}
```

Then open the Dev Container and run `pi` in its terminal.

First configure your preferred provider with `/login` or supply an API key
through your chosen environment configuration. Host credentials in
`~/.pi/agent` are exposed to the container.

The independent upstream project is not affiliated with this Feature.
36 changes: 36 additions & 0 deletions src/pi/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"id": "pi",
"version": "0.1.0",
"name": "Pi Coding Agent",
"description": "Installs the Pi CLI and persists default ~/.pi/agent state.",
"options": {
"install": {
"type": "boolean",
"default": true,
"description": "Install the tool."
},
"persist": {
"type": "boolean",
"default": true,
"description": "Link the default user-state directory to its bind-mounted host directory."
},
"version": {
"type": "string",
"default": "latest",
"description": "npm version for @earendil-works/pi-coding-agent; latest or a concrete version."
}
},
"dependsOn": {
"ghcr.io/devcontainers/features/node:1": {
"version": "lts"
}
},
"mounts": [
{
"type": "bind",
"source": "${localEnv:HOME}/.pi/agent",
"target": "/mnt/devcontainer-features/pi"
}
],
"postCreateCommand": "/usr/local/share/devcontainer-features/pi/setup-persistence.sh"
}
25 changes: 25 additions & 0 deletions src/pi/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
set -euo pipefail

FEATURE_ID="pi"
FEATURE_DIR="/usr/local/share/devcontainer-features/${FEATURE_ID}"
BASE_DIR="/mnt/devcontainer-features/${FEATURE_ID}"

mkdir -p "$FEATURE_DIR" "$BASE_DIR"
cp "${0%/*}/setup-persistence.sh" "$FEATURE_DIR/"
chmod +x "$FEATURE_DIR/setup-persistence.sh"

cat > "${FEATURE_DIR}/feature.env" <<ENVEOF
PERSIST="${PERSIST:-true}"
ENVEOF

if [ "${INSTALL:-true}" != "true" ]; then
echo "[${FEATURE_ID}] Installation disabled."
exit 0
fi

package="@earendil-works/pi-coding-agent"
if [ "${VERSION:-latest}" != "latest" ]; then
package+="@${VERSION}"
fi
npm install --global --ignore-scripts "$package"
137 changes: 137 additions & 0 deletions src/pi/setup-persistence.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
#!/usr/bin/env bash
set -euo pipefail

FEATURE_ID="pi"
FEATURE_DIR="/usr/local/share/devcontainer-features/${FEATURE_ID}"
FEATURE_ENV="${FEATURE_DIR}/feature.env"
if [ -f "${FEATURE_ENV}" ]; then
# shellcheck disable=SC1090
. "${FEATURE_ENV}"
fi

PERSIST="${PERSIST:-true}"
BASE_DIR="/mnt/devcontainer-features/${FEATURE_ID}"

log() {
echo "[${FEATURE_ID}] $*"
}

is_true() {
case "${1:-}" in
true|True|TRUE|1|yes|YES|y|Y) return 0 ;;
*) return 1 ;;
esac
}

ensure_writable_dir() {
local dir="$1"
mkdir -p "$dir"
if [ ! -w "$dir" ]; then
log "Warning: $dir is not writable by $(id -un). Trying to continue."
fi
}

nearest_existing() {
local path="$1"
while [ ! -e "$path" ] && [ "$path" != "/" ]; do
path="$(dirname "$path")"
done
printf '%s\n' "$path"
}

mount_of() {
local path
local mnt

path="$(nearest_existing "$1")"

if command -v findmnt >/dev/null 2>&1; then
mnt="$(findmnt -n -o TARGET -T "$path" 2>/dev/null | head -n1 || true)"
if [ -n "$mnt" ]; then
printf '%s\n' "$mnt"
return 0
fi
fi

if command -v stat >/dev/null 2>&1; then
mnt="$(stat -c '%m' "$path" 2>/dev/null || true)"
if [ -n "$mnt" ]; then
printf '%s\n' "$mnt"
return 0
fi
fi
}

link_dir() {
local src="$1"
local dst="$2"
local backup
local mnt
local src_mnt

src_mnt="$(mount_of "$src")"
if [ -z "$src_mnt" ] || [ "$src_mnt" = "/" ]; then
log "Warning: $src is not on a mounted host path. Leaving $dst untouched."
return 0
fi

ensure_writable_dir "$src"

if [ -L "$dst" ]; then
local current
current="$(readlink "$dst" || true)"
if [ "$current" = "$src" ]; then
log "Already linked: $dst -> $src"
return 0
fi
fi

mnt="$(mount_of "$dst")"
if [ -z "$mnt" ]; then
log "Warning: Cannot determine the mount for $dst. Leaving it untouched."
return 0
fi
if [ "$mnt" != "/" ]; then
log "Warning: $dst is on mount $mnt. Leaving it untouched."
return 0
fi

mkdir -p "$(dirname "$dst")"

if [ -e "$dst" ] || [ -L "$dst" ]; then
if [ -d "$dst" ] && rmdir "$dst" 2>/dev/null; then
log "Removed empty directory: $dst"
else
backup="${dst}.devcontainer-backup"
if [ -e "$backup" ] || [ -L "$backup" ]; then
local index=1
while [ -e "${backup}.${index}" ] || [ -L "${backup}.${index}" ]; do
index=$((index + 1))
done
backup="${backup}.${index}"
fi
if mv "$dst" "$backup"; then
log "Moved existing $dst to $backup"
else
log "Error: Failed to move $dst. Persistence was not configured."
return 1
fi
fi
fi

if ln -sfn "$src" "$dst"; then
log "Linked: $dst -> $src"
else
log "Error: Failed to create symlink $dst -> $src. Persistence was not configured."
return 1
fi
}

if ! is_true "$PERSIST"; then
log "Persistence disabled."
exit 0
fi

ensure_writable_dir "$BASE_DIR"

link_dir "$BASE_DIR" "$HOME/.pi/agent"
Loading