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
dotfiles/ -> Dotfiles repository cloning and installation
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 container creation, recreation, and post-creation setup.

Every plugin implements plugin.Plugin:

type Plugin interface {
Name() string
PreContainerRun(ctx context.Context, req *PreContainerRunRequest) (*PreContainerRunResponse, error)
PostContainerCreate(ctx context.Context, req *PostContainerCreateRequest) (*PostContainerCreateResponse, 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

PostContainerCreate runs inside a freshly created container, between postCreateCommand and postStartCommand. It does not run on resume or restart (the effects are baked into the snapshot).

The request provides an Exec callback for running commands inside the container:

FieldDescription
WorkspaceIDUnique workspace identifier
WorkspaceDir~/.crib/workspaces/{id}/
ContainerIDRunning container ID
RemoteUserUser inside the container
WorkspaceFolderPath inside container (e.g. /workspaces/proj)
Execfunc(ctx, cmd, user, workDir) ([]byte, error)

Return nil, nil for no-op. Errors are logged and skipped (fail-open).

Plugins that only need one hook can embed plugin.BasePlugin to get no-op defaults for the other:

type Plugin struct {
plugin.BasePlugin // provides no-op PreContainerRun and PostContainerCreate
// ...
}

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 {
plugin.BasePlugin // no-op defaults for hooks you don't need
}
func New() *Plugin { return &Plugin{} }
func (p *Plugin) Name() string { return "your-plugin" }
// Implement only the hooks you need. BasePlugin provides no-ops for the rest.
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()
finalize() finalize()
execPluginCopies() execPluginCopies()
chownPluginVolumes() (skip on restart)
resolveRemoteUser() (already set)
saveResult() (early) saveResult() (early)
setupContainer() resumeHooks()
runCreateHooks() postStartCommand
onCreateCommand postAttachCommand
updateContentCommand
postCreateCommand
RunPostContainerCreate() (not called on resume)
plugin1.PostContainerCreate()
plugin2.PostContainerCreate()
runStartHooks()
postStartCommand
postAttachCommand
commitSnapshot()
saveResult() (final) saveResult() (final)

For user-facing documentation on what each plugin does and how to configure it, see Built-in Plugins.

Implementation notes for contributors:

  • coding-agents: Uses FileCopy (not bind mounts) because Claude Code does atomic renames on ~/.claude.json. Supports host and workspace credential modes via customizations.crib.coding-agents.credentials.
  • dotfiles: Uses PostContainerCreate (not PreContainerRun) because it needs to run git clone inside the container via the Exec callback. Configured via global config, not devcontainer.json.
  • shell-history: Bind-mounts a directory (not a file) to avoid EBUSY on atomic renames when the shell saves history.
  • ssh: Four components (agent forwarding, config, public keys, git signing config). Private keys are never copied. Signing works via the forwarded agent.

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).