Skip to content

Plugin Development

Guide for writing bundled (in-process) plugins. External plugin support is planned but not yet implemented.

internal/plugin/
plugin.go -> Plugin interface and types
manager.go -> Registration and event dispatch
codingagents/ -> Claude Code credentials plugin
shellhistory/ -> Persistent shell history plugin
ssh/ -> SSH agent forwarding, keys, and git signing

Plugins are registered in cmd/root.go via setupPlugins() and dispatched by the engine during both upSingle() (initial container creation) and restartRecreateSingle() (container recreation triggered by crib restart).

Every plugin implements plugin.Plugin:

type Plugin interface {
Name() string
PreContainerRun(ctx context.Context, req *PreContainerRunRequest) (*PreContainerRunResponse, error)
}

PreContainerRunRequest provides context about the workspace and the container that is about to be created:

FieldDescription
WorkspaceIDUnique workspace identifier
WorkspaceDir~/.crib/workspaces/{id}/ (for staging files)
SourceDirProject root on host
Runtime"docker" or "podman"
ImageNameResolved image name
RemoteUserUser inside the container (from config)
WorkspaceFolderPath inside container (e.g. /workspaces/proj)
ContainerNamecrib-{workspace-id}
Customizationscustomizations.crib from devcontainer.json

The Customizations field contains the crib namespace from devcontainer.json customizations. Plugins can look up their own config key:

func getMyConfig(customizations map[string]any) map[string]any {
if customizations == nil {
return nil
}
if v, ok := customizations["my-plugin"]; ok {
if m, ok := v.(map[string]any); ok {
return m
}
}
return nil
}

Example devcontainer.json:

{
"customizations": {
"crib": {
"my-plugin": {
"setting": "value"
}
}
}
}

PreContainerRunResponse specifies what to inject. Return nil for no-op.

FieldTypeMerge ruleWhen applied
Mounts[]config.MountAppended in plugin orderBefore container creation
Envmap[string]stringMerged, last plugin winsBefore container creation
RunArgs[]stringAppended in plugin orderBefore container creation
Copies[]plugin.FileCopyAppended in plugin orderAfter container creation

FileCopy describes a file to inject into the container after creation via docker exec:

type FileCopy struct {
Source string // path on host
Target string // path inside container
Mode string // chmod mode (e.g. "0600"), empty for default
User string // chown user (e.g. "vscode"), empty for default
IfNotExists bool // if true, skip copy when target already exists
}

Copies run as root and use sh -c "mkdir -p <dir> && cat > <file>" with stdin piped.

internal/plugin/yourplugin/
plugin.go
plugin_test.go
package yourplugin
import (
"context"
"github.com/fgrehm/crib/internal/plugin"
)
type Plugin struct{}
func New() *Plugin { return &Plugin{} }
func (p *Plugin) Name() string { return "your-plugin" }
func (p *Plugin) PreContainerRun(_ context.Context, req *plugin.PreContainerRunRequest) (*plugin.PreContainerRunResponse, error) {
// Return nil for no-op (e.g. when prerequisite files don't exist).
// Return response with mounts/env/copies as needed.
return nil, nil
}

In cmd/root.go:

import "github.com/fgrehm/crib/internal/plugin/yourplugin"
func setupPlugins(eng *engine.Engine, d *oci.OCIDriver) {
// ...
mgr.Register(yourplugin.New())
// ...
}

Follow the pattern in existing plugins. Tests should cover:

  • Plugin name
  • Happy path (returns expected mounts/env/copies)
  • No-op path (returns nil when prerequisites missing)
  • User variants (vscode, root, empty)
  • File staging (if applicable)

Plugins can stage files in {req.WorkspaceDir}/plugins/{plugin-name}/. This directory persists across container recreations but is scoped to the workspace.

The container doesn’t exist during pre-container-run, so you can’t run getent or similar. Use the convention:

func inferRemoteHome(user string) string {
if user == "" || user == "root" {
return "/root"
}
return "/home/" + user
}

This matches devcontainer base images and covers the vast majority of cases.

Never bind-mount a single file if any process inside the container might do an atomic rename on it (write to .file.new, then rename() over the original). This causes EBUSY errors on Docker/Podman because the mount holds the inode.

Instead, bind-mount the parent directory and point env vars to the file inside it. This lets processes create temp files alongside and rename freely.

Example from the shell-history plugin:

  • Mount: ~/.crib/workspaces/{id}/plugins/shell-history/ -> ~/.crib_history/
  • Env: HISTFILE=~/.crib_history/.shell_history

The coding-agents plugin uses FileCopy instead of mounts for the same reason (Claude Code does atomic renames on ~/.claude.json).

  • Return error for problems that should be visible to the user.
  • The manager logs plugin errors as warnings and skips the plugin (fail-open), so one broken plugin doesn’t block container creation.
  • Return nil, nil for graceful no-op (e.g. prerequisite files don’t exist on the host).
upSingle() restartRecreateSingle()
buildImage() removeContainer()
buildRunOptions() <- base config buildRunOptions() <- base config
runPreContainerRunPlugins() runPreContainerRunPlugins()
manager.RunPreContainerRun() manager.RunPreContainerRun()
plugin1.PreContainerRun() plugin1.PreContainerRun()
plugin2.PreContainerRun() plugin2.PreContainerRun()
...merge responses... ...merge responses...
merge into RunOptions merge into RunOptions
driver.RunContainer() driver.RunContainer()
execPluginCopies() execPluginCopies()
setupAndReturn() setupAndReturn()

Shares Claude Code credentials with containers. Two modes:

Host mode (default): Copies ~/.claude/.credentials.json from the host into the container. Uses FileCopy (not bind mounts) because Claude Code does atomic renames on ~/.claude.json.

Workspace mode: Configured via devcontainer.json:

{
"customizations": {
"crib": {
"coding-agents": {
"credentials": "workspace"
}
}
}
}

In workspace mode, host credentials are not injected. Instead, a persistent directory is bind-mounted to ~/.claude/ inside the container. The user authenticates inside the container on first use, and credentials survive container rebuilds.

A minimal ~/.claude.json with {"hasCompletedOnboarding":true} is re-injected via FileCopy on each rebuild to skip the Claude Code onboarding flow. This file is not persisted because Claude Code does atomic renames on it.

Persists bash/zsh history across container recreations by bind-mounting a history directory from workspace state and setting HISTFILE.

Shares SSH configuration and enables agent forwarding. Four components:

  1. SSH agent forwarding: Bind-mounts the host’s SSH_AUTH_SOCK socket into the container at /tmp/ssh-agent.sock and sets the SSH_AUTH_SOCK env var. No-op if the agent is not running.

  2. SSH config: Copies ~/.ssh/config into the container so host aliases, proxy settings, and other SSH config are available.

  3. SSH public keys: Copies *.pub files from ~/.ssh/ into the container. Private keys are not copied. Git commit signing works via the forwarded SSH agent (requires OpenSSH 8.2+, which can sign using only the public key plus the agent).

  4. Git SSH signing config: If the host’s git config has gpg.format = ssh, the plugin extracts signing-related settings (user.name, user.email, user.signingkey, gpg.format, gpg.ssh.program, commit.gpgsign, tag.gpgsign) and generates a minimal .gitconfig for the container. The user.signingkey path is rewritten from the host home to the container home. Skipped entirely if git is not configured for SSH signing.

Currently all plugins are bundled Go code compiled into the crib binary. A planned external plugin system will let users write plugins as standalone executables in any language. The protocol uses environment variables for input and JSON on stdout for output.

To illustrate what this looks like, here are the two existing bundled plugins reimagined as bash scripts.

#!/usr/bin/env bash
# shell-history — persist bash/zsh history across container recreations
set -euo pipefail
PLUGIN_DIR="${CRIB_WORKSPACE_DIR}/plugins/shell-history"
HIST_FILE=".shell_history"
# Infer remote home from user.
if [ -z "${CRIB_REMOTE_USER}" ] || [ "${CRIB_REMOTE_USER}" = "root" ]; then
REMOTE_HOME="/root"
else
REMOTE_HOME="/home/${CRIB_REMOTE_USER}"
fi
MOUNT_TARGET="${REMOTE_HOME}/.crib_history"
# Create plugin dir and touch the history file if it doesn't exist.
mkdir -p "${PLUGIN_DIR}"
[ -f "${PLUGIN_DIR}/${HIST_FILE}" ] || touch "${PLUGIN_DIR}/${HIST_FILE}"
# Output JSON response.
cat <<JSON
{
"mounts": [
{
"type": "bind",
"source": "${PLUGIN_DIR}",
"target": "${MOUNT_TARGET}"
}
],
"env": {
"HISTFILE": "${MOUNT_TARGET}/${HIST_FILE}"
}
}
JSON
#!/usr/bin/env bash
# coding-agents — inject Claude Code credentials into the container
set -euo pipefail
CREDS="${HOME}/.claude/.credentials.json"
# No-op if credentials don't exist on the host.
if [ ! -f "${CREDS}" ]; then
echo '{}'
exit 0
fi
# Scope staging directory per agent so different tools don't collide.
PLUGIN_DIR="${CRIB_WORKSPACE_DIR}/plugins/coding-agents/claude-code"
mkdir -p "${PLUGIN_DIR}"
# Stage credentials.
cp "${CREDS}" "${PLUGIN_DIR}/credentials.json"
chmod 600 "${PLUGIN_DIR}/credentials.json"
# Generate minimal config to skip onboarding.
echo '{"hasCompletedOnboarding":true}' > "${PLUGIN_DIR}/claude.json"
# Infer remote home and owner.
if [ -z "${CRIB_REMOTE_USER}" ] || [ "${CRIB_REMOTE_USER}" = "root" ]; then
REMOTE_HOME="/root"
OWNER="root"
else
REMOTE_HOME="/home/${CRIB_REMOTE_USER}"
OWNER="${CRIB_REMOTE_USER}"
fi
# Output JSON response with file copies (not mounts, to avoid EBUSY on
# atomic renames).
cat <<JSON
{
"copies": [
{
"source": "${PLUGIN_DIR}/credentials.json",
"target": "${REMOTE_HOME}/.claude/.credentials.json",
"mode": "0600",
"user": "${OWNER}"
},
{
"source": "${PLUGIN_DIR}/claude.json",
"target": "${REMOTE_HOME}/.claude.json",
"user": "${OWNER}"
}
]
}
JSON

External plugins receive context via environment variables set by crib:

VariableDescription
CRIB_EVENTEvent name (e.g. pre-container-run)
CRIB_WORKSPACE_IDWorkspace identifier
CRIB_WORKSPACE_DIRState directory (~/.crib/workspaces/{id}/)
CRIB_SOURCE_DIRProject root on host
CRIB_RUNTIMEdocker or podman
CRIB_REMOTE_USERContainer user (from config)
CRIB_WORKSPACE_FOLDERWorkspace path inside container
CRIB_CONTAINER_NAMEContainer name (crib-{workspace-id})
CRIB_VERBOSE1 if verbose mode is on

Plugins write JSON to stdout. Stderr is for diagnostic messages (suppressed in normal mode, shown in verbose mode). Exit code 0 means success, non-zero means the plugin failed (fail-open: crib logs a warning and continues).

Plugin configuration is delivered via stdin as JSON (from customizations.crib.plugins.<name> in devcontainer.json or ~/.config/crib/config.toml).