#14045·ohmyzsh

feat(cli): add `omz generate plugin` to scaffold custom plugins

Author: robbyrussellCreated Sep 5, 2026Updated Sep 5, 2026
LabelsFeature

The problem

"How do I make my own plugin?" is one of the most common questions we get, and the honest answer today is "look at custom/plugins/example/, read the wiki, then go reverse-engineer a few plugins in plugins/." The example stub is three lines of comments. Everything people actually need to know... the $+commands guard, how completions get picked up, what a README should look like... lives in the wiki or in other people's code.

We have a lot of accumulated knowledge about what a good plugin looks like. I want the tool to just hand it to you.

This is an experiment. The goal is to lower the bar enough that more people play around with custom plugins for their own workflows... not necessarily to submit them upstream, just to tinker. If it works, it also gives us a natural on-ramp for the ones that do turn into contributions.

What I'm building: omz generate plugin

A rails generate-style scaffold. Run it, answer a few questions (or pass flags), get a working, well-commented plugin in $ZSH_CUSTOM/plugins/<name>/ that you can start editing right away.

Usage: omz generate plugin [<name>] [options]

  -d, --description <text>  One-line description for the README and file header
  -c, --command <cmd>       CLI tool this plugin wraps. Adds a $+commands guard
                            and scaffolds a completion function
      --no-completion       Skip the completion scaffold
      --enable              Add the plugin to plugins=() in .zshrc when done
                            (restarts your shell)
  -y, --yes                 Never prompt; use defaults for anything not given

Anything you don't pass as a flag gets asked interactively. In a non-interactive shell (scripts, CI, redirected stdin) it just uses defaults, and a missing name is a usage error.

What a session looks like

$ omz generate plugin
omz generate plugin: Plugin name: foo
omz generate plugin: Short description [foo plugin for Oh My Zsh]: Shortcuts for the foo CLI
omz generate plugin: Command-line tool this plugin wraps (blank for none) [foo]:
omz generate plugin: Add a completion function (_foo)? [Y/n] y
omz generate plugin: Does foo generate its own zsh completion, e.g. "foo completion zsh"? [y/N] n
omz generate plugin: Add foo to plugins=() in your .zshrc now? [y/N] n

      create  ~/.oh-my-zsh/custom/plugins/foo
      create  ~/.oh-my-zsh/custom/plugins/foo/foo.plugin.zsh
      create  ~/.oh-my-zsh/custom/plugins/foo/README.md
      create  ~/.oh-my-zsh/custom/plugins/foo/_foo

omz generate plugin: plugin 'foo' generated.

Next steps:

  1. Edit it:     vim ~/.oh-my-zsh/custom/plugins/foo/foo.plugin.zsh
  2. Try it now:  omz plugin load foo
  3. Keep it:     omz plugin enable foo

Docs: https://github.com/ohmyzsh/ohmyzsh/wiki/Customization#overriding-and-adding-plugins

That "try it now" step is the part I like most... omz plugin load already exists and gives you an instant feedback loop without touching .zshrc.

What gets generated

The templates are the real feature. Each file is heavily commented with the why, so the scaffold doubles as the plugin-authoring guide we don't currently have in the repo.

  • <name>.plugin.zsh: the $+commands guard (with a comment explaining why), an Aliases section with one example, a Functions section with a commented example, a note that _<name> is picked up via $fpath automatically, and the standard 0= plugin-dir idiom (commented out) for when you need it.
  • README.md: exactly the shape our plugins already follow (# foo plugin, "To use it, add foo to the plugins array...", the Aliases table).
  • _<name>: a small hand-written _arguments skeleton with a subcommand list and a case $state, plus links to the zsh completion docs.
  • If the user says the tool generates its own completion (foo completion zsh), we use the cached-completion block from gh/gcx/ruff instead of the hand-written skeleton, with comments explaining what each piece does.
Draft: foo.plugin.zsh
# foo plugin
#
# Shortcuts and completion for the foo CLI
#
# This file is sourced by Oh My Zsh in every new shell, so keep it fast.
# Avoid running external commands at the top level unless you really need to.

# Only define anything if the tool is actually installed. Without this guard,
# people who don't have foo get aliases that fail with "command not found".
if (( ! $+commands[foo] )); then
  return
fi

#
# Aliases
#
# Document every alias in README.md. Short, obvious, and few beats many.
#

alias foos='foo status'

#
# Functions
#
# Anything that needs arguments in the middle, or more than one command,
# belongs here rather than in an alias.
#

# function foo_current() {
#   foo status --short "$@"
# }

#
# Completion
#
# Oh My Zsh adds this directory to $fpath, so the `_foo` file next to this one
# is picked up automatically. There is nothing to do here.
#

#
# Need the path to this plugin's own directory (for data files, a lib/ dir...)?
# This is the standard way to get it, since $0 is not reliable on its own:
# https://zdharma-continuum.github.io/Zsh-100-Commits-Club/Zsh-Plugin-Standard.html
#
# 0="${ZERO:-${${0:#$ZSH_ARGZERO}:-${(%):-%N}}}"
# 0="${${(M)0:#/*}:-$PWD/$0}"
# source "${0:A:h}/lib/helpers.zsh"

When no wrapped command is given, the guard and the example alias are dropped and the Aliases section just says "add yours here".

Draft: README.md
# foo plugin

Shortcuts for the foo CLI

To use it, add `foo` to the plugins array in your zshrc file:

```zsh
plugins=(... foo)
```

## Aliases

| Alias  | Command      | Description     |
| :----- | ------------ | :-------------- |
| `foos` | `foo status` | Show foo status |

## Requirements

This plugin requires [foo](https://example.com) to be installed.

With no wrapped command: the Aliases section becomes This plugin does not add any aliases. and Requirements is dropped.

Draft: _foo (hand-written skeleton)
#compdef foo
#
# Completion for foo.
#
# This is a starting point. Delete what you don't need. Two references:
#   https://zsh.sourceforge.io/Doc/Release/Completion-System.html
#   https://github.com/zsh-users/zsh/blob/master/Etc/completion-style-guide

local -a subcommands
subcommands=(
  'status:Show the current status'
  'run:Run something'
  'help:Show help for a command'
)

_arguments -C \
  '(-h --help)'{-h,--help}'[show help]' \
  '(-v --version)'{-v,--version}'[show the version]' \
  '1: :->subcommand' \
  '*:: :->args'

case $state in
  subcommand)
    _describe -t commands 'foo subcommand' subcommands
    ;;
  args)
    case $words[1] in
      status) _arguments '--short[one-line output]' ;;
      run)    _files ;;
    esac
    ;;
esac
Draft: cached-completion block (when the tool generates its own)

Replaces the "Completion" comment block in foo.plugin.zsh; no _foo file is written.

#
# Completion
#
# foo generates its own zsh completion, so we cache it instead of
# maintaining a `_foo` file by hand.
#
# On the first shell after installing this plugin the cache file doesn't exist
# yet, so compinit hasn't bound it. Do that manually here.
if [[ ! -f "$ZSH_CACHE_DIR/completions/_foo" ]]; then
  typeset -g -A _comps
  autoload -Uz _foo
  _comps[foo]=_foo
fi

# Regenerate the cache in the background so it never blocks shell startup.
# TMPPREFIX puts the temp file next to the destination, which keeps the move
# atomic.
zmodload -F zsh/files b:zf_mv
() {
  local TMPPREFIX="$ZSH_CACHE_DIR/completions/_foo"
  zf_mv -f -- =( foo completion zsh ) "$TMPPREFIX"
} &|

Design notes

Decisions I've made up front, mostly so they're written down somewhere.

Name validation is the one security gate. The name ends up in a mkdir path and, via --enable, inside the awk program omz plugin enable generates to rewrite .zshrc. So: ^[a-z0-9][a-z0-9_-]*$, max 64 chars, validated once at the top before it's interpolated anywhere. That rejects spaces, ../x, .hidden, -x, quotes, backticks and newlines in one go. Uppercase gets a friendly "did you mean foo?".

Collisions. An existing $ZSH_CUSTOM/plugins/<name> is a hard error (no --force in v1). A name that matches a builtin plugin gets a warning and a confirm, but isn't blocked... shadowing a builtin is a supported, documented use case.

Ask everything, then write everything. No file is touched until the last question is answered, so Ctrl-C during the prompts never leaves a half-made directory. If a write fails mid-way we remove exactly the files we created and rmdir the directory (which safely refuses if anything unexpected is in there). No rm -rf on an interpolated path.

Templates live on disk in templates/generators/plugin/, not as heredocs in cli.zsh. Three reasons: the templates are ~130 lines of prose and comments that have nothing to do with CLI logic; an unquoted heredoc means backslash-escaping every $ in a file that is basically nothing but $+commands and ${(%):-%N}, and that will rot; and on-disk templates are an easy place for contributors to improve the guidance without touching the CLI. Placeholders are %name%, %command%, %description%, %compgen%, substituted with zsh ${content//\%name\%/$name} rather than sed, since a description containing & or | would silently corrupt sed output. They can also be added to the zsh -n loop in CI.

We never run the wrapped tool. Detecting whether foo completion zsh works would mean executing an arbitrary binary the user just named during a scaffolding command. We ask instead. Auto-detect behind an explicit confirmation is a fine follow-up.

Prompting. _omz::log prompt + builtin read -r for free text (same as omz pr test), _omz::confirm for yes/no. Not vared... it needs zle and fails under zsh -f and inside $(...), which are exactly the fallback paths this has to survive. Prompt only when [[ -o interactive && -t 0 ]], so redirected stdin doesn't silently EOF through every question.

--enable reuses _omz::plugin::enable (backup, zsh -n check, rollback, all already there). It ends in _omz::reload which execs a new shell, so it has to be the last thing create does, after the file list and next steps are printed.

Small stuff. It's a top-level omz generate plugin, not omz plugin create... generate says exactly what it does, it gets its own line in omz help instead of hiding under plugin, and omz generate theme can follow the same shape later. Options are parsed with zparseopts (avoiding -F, which is 5.8+)... nothing in cli.zsh uses it today, but five options with values is where hand-rolled parsing gets buggy. Helpers are _omz::generate::plugin::* like the rest of the file.

Integration checklist (lib/cli.zsh and friends)
  • _omz::help: add generate <command> Generate a custom plugin from a template
  • New _omz::generate dispatcher (same shape as _omz::plugin) and _omz::generate::plugin plus small helpers (::ask, ::validate_name, ::render, ::created)
  • _omz completion: add generate to the top-level commands, plugin as its subcommand, and a generate::plugin) case offering the options
  • README.md "Custom Plugins And Themes": one sentence and the command
  • .github/workflows/main.yml: add ./templates/generators/plugin/*.zsh-template to the zsh -n loop
  • Wiki Customization page: link to the command (not blocking)
Testing
  • lib/tests/generate-plugin.test.zsh, same shape as lib/tests/cli.test.zsh: ZSH_CUSTOM=$(mktemp -d), drive the --yes path, diff the file list, and zsh -n every generated file (the same check CI already applies to real plugins, so it catches template rot).
  • A name-validation table: for each bad input (Foo, a b, ../evil, .hidden, -x, foo`id`, a newline...) assert non-zero exit and that $ZSH_CUSTOM is unchanged. The second half is what actually proves the traversal defence.
  • ::ask is a separate function with no interactivity check so it can be tested directly with < <(print -l ...).
  • lib/tests/*.test.zsh isn't wired into CI today. A one-line step in main.yml would fix that (and finally run the existing awk tests). Probably its own small PR.

Out of scope for v1

--alias flags, --force, git init, opening $EDITOR, scaffolding into the builtin plugins/ dir, --from <existing>.

Follow-ups that fall out of this nicely, in rough order of value:

  1. omz theme create: same rendering machinery, a templates/theme/ dir, one more function.
  2. --upstream: scaffold into plugins/ for a PR to this repo, and print the alias conditions from CONTRIBUTING.md plus the "find testers to +1" note as part of the next steps. Turns the scaffold into a contribution-quality gate.
  3. Completion auto-detect behind a confirmation.

Filing this so there's a place to track it. PR to follow.


Co-authored with Claude Code (used for the codebase research and drafting this proposal; templates were syntax-checked with zsh -n)