# Plugin Conventions

> WHAT: Conventions for files under plugins/ - numeric load-order prefix, `command -v` guard, disable-switch contract, no-blocking-work-at-source-time. WHEN: User asks to add a new plugin, move an integration into a plugin, or debug why a plugin isn't loading. DO-NOT: Skip the `command -v` guard; perform network/subprocess work at source time; assume the plugin loads by default.

- Skill: `weikinhuang/plugin-conventions` (Agent Skill)
- Install (CLI): `npx skillmds@latest add weikinhuang/plugin-conventions`
- Raw SKILL.md: https://api.skillmd.com/api/skills/weikinhuang/plugin-conventions/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Integrations & APIs
- Author: weikinhuang (https://skillmd.com/u/weikinhuang)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/weikinhuang/plugin-conventions

---


# Plugin Conventions

Plugins under `plugins/` integrate one external tool each. They load during the `plugin` phase of the dotenv chain -
after `completion`, before `prompt` - in basename order. Getting the shape right is what makes them cheap to have on
every shell.

## When this applies

- Adding a new integration for a CLI tool (`fzf`, `direnv`, `bat`, `kubectl`, …).
- Moving setup code out of a phase file and into a plugin because it's becoming too large or is opt-in.
- Debugging "my plugin isn't loading" / "shell startup got slow".

## The file shape

Every plugin starts with this exact header and guard - copy it verbatim, only swap `<tool>` and the purpose line:

```bash
# shellcheck shell=bash
# <One-line purpose>.
# SPDX-License-Identifier: MIT

# @see https://<upstream-url>
command -v <tool> &>/dev/null || return
```

No shebang. Plugins are sourced, not executed. The guard is one line: `command -v <tool> &>/dev/null || return` - do NOT
drop the `|| return` tail, and do NOT expand it into a multi-line `if ! command -v …; then return; fi` block unless you
need to do extra cleanup in the miss path. The one-liner is what every other plugin uses; consistency makes missing
guards obvious in review.

## Numeric prefix tiers

The numeric prefix controls load order. Pick the earliest tier that orders correctly. Reusing an existing tier is
cheaper than inventing a new one - `DOT_PLUGIN_DISABLE_<name>` strips the prefix, so renumbering later is disruptive.

| Prefix | What lives here                                                                     |
| ------ | ----------------------------------------------------------------------------------- |
| `00-`  | Baseline shell options and hooks that must load first (`bash-opts`, `chpwd-hook`).  |
| `05-`  | Pre-tool defaults, before tool wrappers can override them (`ls`).                   |
| `10-`  | Env-var / alias integrations - most tools (`fzf`, `eza`, `ripgrep`, `bat`, `less`). |
| `20-`  | Heavy or lazy-loaded integrations (`nvm`).                                          |
| `30-`  | Completion / pager / cache-backed integrations (`git`, `gh`, `npm`, `delta`).       |
| `80-`  | Container runtimes (`podman`).                                                      |
| `90-`  | Platform / late-bound (`docker`, `termux`).                                         |

Adding a new tier number (e.g. `40-`, `50-`, `60-`) changes the mental model - ask the user first.

## Disable switch

Each plugin is disablable via `DOT_PLUGIN_DISABLE_<basename>=1`, with the numeric prefix stripped from the basename:

- `plugins/10-fzf.sh` → `DOT_PLUGIN_DISABLE_fzf=1`
- `plugins/30-git.sh` → `DOT_PLUGIN_DISABLE_git=1`

Document the disable knob in `REFERENCE.md` when adding a new plugin.

## Load-by-default vs opt-in

Only two plugins load by default: `00-bash-opts.sh` and `00-chpwd-hook.sh`. Everything else requires
`DOT_INCLUDE_BUILTIN_PLUGINS=1` in the user's environment. Adding a new default-loaded plugin is a big deal - **ask
first**.

## Respect existing state

Users override behavior in `~/.bash_local` or `~/.bash_local.d/*.sh`, sourced before plugins run. Your plugin must be
additive - never clobber an already-set value.

```bash
# Good: only sets if the user hasn't already.
: "${FZF_DEFAULT_OPTS:=--height 40% --reverse}"

# Bad: always overwrites, breaks user customization.
export FZF_DEFAULT_OPTS="--height 40% --reverse"
```

## No blocking work at source time

Plugins run in every interactive shell. Slow plugins mean slow shells. At source time you may:

- Set env vars, aliases, define functions.
- Register completions.
- `source` lightweight shell files.

You may **not**:

- Make network calls.
- Call `$(command ...)` subprocesses that take more than a few ms.
- Run any loop you wouldn't want on every `cd` into a directory that opens a new terminal.

If a plugin needs expensive work, lazy-load it via a wrapper function that runs the setup the first time the command is
invoked.

## Tests

Non-trivial plugins get a bats spec under `tests/plugins/<basename>.bats` - note the `.bats` extension, matching the
source basename (prefix included). Example: `plugins/30-zoxide.sh` → `tests/plugins/30-zoxide.bats`. See the
`bats-test-conventions` skill.

## Anti-patterns

- **No `command -v` guard.** Shell startup crashes when the tool isn't installed.
- **Expensive subprocess calls at source time.** `$(kubectl version)` on every shell start adds 500ms+.
- **Clobbering existing env vars.** Use `: "${VAR:=default}"` instead of `export VAR=default`.
- **Using a made-up tier.** `45-foo.sh` works functionally but breaks the shared load-order mental model.
- **Duplicating a phase-file responsibility.** A simple alias belongs in `dotenv/aliases.sh`, not a plugin. Plugins are
  for tool integrations where `command -v` guarding and opt-in loading matter.
- **Skipping the `REFERENCE.md` update.** Every new plugin row belongs in the Tool defaults table.
- **Editing `external/` from a plugin.** Wrap it instead; never modify vendored files in place.

## Workflow

1. Confirm the tier: scan `plugins/` for a similar plugin and match its prefix.
2. Write the file with the header, guard, and additive setup.
3. Add `DOT_PLUGIN_DISABLE_<name>=1` support documentation in `REFERENCE.md`.
4. Add a bats spec under `tests/plugins/<basename>.bats` if behavior is non-trivial.
5. `./dev/lint-shell.sh` and (if tests exist) `./dev/test-bats-docker.sh tests/plugins/<basename>.bats`.
6. Smoke test: `DOT_INCLUDE_BUILTIN_PLUGINS=1 env -i PATH="$PATH" HOME="$HOME" bash -l`.

