omarfakih1/zcomplete

Mistype a command and zcomplete runs the one you meant, worked out from the commands you usually run. zsh, bash and fish.

31

stars

33

commits

Rust

primary language

Aug 25, 2026

updated

README

zcomplete

You mistype a command. Instead of command not found, zcomplete figures out which command you meant from the ones you actually run, and offers to run it with your arguments.

$ mkd build
zcomplete: run mkdir instead of 'mkd'? [Y/n] y

$ gti status
zcomplete: run git instead of 'gti'? [Y/n] y
On branch main
nothing to commit, working tree clean

$ cargo tset
error: no such command: `tset`
zcomplete: run cargo test? [Y/n] y

zsh, bash and fish. One binary, one dependency (libc), about 550 KB.

Install

curl -fsSL https://raw.githubusercontent.com/omarfakih1/zcomplete/main/install.sh | sh

That downloads the binary, sets up each shell it finds, and offers to seed the database from your history. It asks before touching any config file. -s -- -y answers yes to everything.

Then restart your shell, or exec $SHELL. Done.

The installer checks the binary against its published sha256 and refuses to install if the checksum is missing or wrong. macOS and Linux, arm64 and x86-64. --prefix=DIR defaults to ~/.local. Replace | sh with | less to read install.sh first.

From cargo

cargo install --git https://github.com/omarfakih1/zcomplete
zcomplete init --all && zcomplete import

init --all adds one line to the config of every shell you have. Run it twice and the second run does nothing. It appends and never rewrites, so the rest of your config is untouched. --zsh, --bash or --fish picks one.

import reads your history file and asks the shell for its aliases, functions and builtins. Those aren't on PATH, so without it they'd be discarded as words that aren't commands.

The line init writes, if you'd rather add it yourself:

shelllinefile
zsheval "$(zcomplete init zsh)"~/.zshrc
basheval "$(zcomplete init bash)"~/.bashrc
fishzcomplete init fish | source~/.config/fish/config.fish

(zcomplete init zsh without the dashes prints the integration script. That's what the line above runs. You don't type it yourself.)

zcomplete doctor says what's set up and what isn't.

At the prompt

key
y or Enterrun it
n or ileave it alone
ufix the subcommand too, when one is offered
1-9pick from the list when the match is unclear

One keypress, no Enter. u is for a line where both words are wrong:

$ zcom he
zcomplete: run zcomplete instead of 'zcom'?  u: also he -> help [Y/n] u
zcomplete 0.1.2 - run the command you meant

y there would run zcomplete he and let it fail.

Modes

zcomplete safe      # confirm every correction (default)
zcomplete unsafe    # run ordinary corrections, confirm dangerous ones
zcomplete bypass    # never confirm
zcomplete off       # stop correcting

The mode is read on every correction, so a change reaches shells that are already open. ZCOMPLETE_MODE=safe ./script.sh overrides it for one command.

Dangerous means listed in src/safety.rs: rm, dd, mkfs*, git push --force, git reset --hard, terraform destroy, kubectl delete, recursive chmod, curl ... | sh, and about thirty more. Flags are read the way the shell reads them, so -rf, -r -f, --force and a -- terminator all count, and git clean -n is not treated like git clean -fd.

What it learns

Every command you run gets a rank, and ranks decay with age:

score = rank × 4      used within the hour
        rank × 2      within the day
        rank ÷ 2      within the week
        rank ÷ 4      older

Commands you ran in the current directory count for more, so ma gives make inside a project and man everywhere else.

A typed word is matched four ways: prefix (mkdmkdir), initials (dcdocker-compose), subsequence (dkrdocker) and typo (gtigit, slls). Case is ignored throughout, so a stuck shift key (GIT) resolves like anything else. Match quality decides the winner and usage only breaks ties. Sorting the other way round makes gti mean gtimeout.

Two things hold in every mode, bypass included:

  • Nothing is suggested that isn't a command. A word enters the database only if the shell could resolve it at the time, and every candidate is checked against PATH again before you're offered it.
  • Non-interactive shells are left alone. No controlling terminal means no correction, so scripts and CI never get rewritten.

Subcommands

git sttaus, cargo tset, docker psu. No list of tools is hardcoded. Second words get counted, and a command needs two of them to come back before its second argument is read as a verb at all. So git status and git commit qualify git within a day, and the foo in grep foo file.c never qualifies grep. For a command it knows nothing about, zcomplete reads its --help once and caches the answer, which is why git sttaus works on a fresh install.

A subcommand can't be checked against PATH ahead of time, so it's corrected after the command fails rather than instead of running it. That's safe because a command that failed on its verb did nothing. A line that failed for any other reason is left alone, and a subcommand is only learned once it has exited zero, so git sttaus can never teach zcomplete that git has a sttaus.

Two limits: one command per line (cp a b && git sttaus is refused, since rerunning would repeat the copy), and the first two words only (npm run buidl is out of reach).

Answers

Confirm mkdmkdir three times and it becomes the direct answer. Refuse the same suggestion twice and it retires. Or set it yourself:

zcomplete bind gs git        # gs always means git
zcomplete unbind gs
zcomplete ignore sl          # never suggest sl

Commands

zcomplete init --all             set up every shell you have (--zsh, --bash, --fish)
zcomplete stats [-n N]           what it has learned, strongest first
zcomplete stats <command>        subcommands it would offer for one command
zcomplete query <word>           what a word would resolve to (--score for detail)
zcomplete query <cmd> <word>     the same, for a subcommand
zcomplete import [zsh|bash|fish] seed from history (--dry-run to preview)
zcomplete forget <command>...    unlearn; --all empties everything
zcomplete ignore [<command>...]  list, add to, or --remove from the ignore list
zcomplete bind <word> <command>  pin a shortcut; unbind removes it
zcomplete mode                   show the current mode
zcomplete safe | unsafe | bypass set it
zcomplete on | off               enable or disable without editing shell config
zcomplete doctor                 check the installation

Speed

A command that worked needs no correction, so nothing starts for it. The hook appends one line to a per-session file. Per command, on an M-series Mac:

zsh                     0.07 ms
fish                    0.21 ms
bash                    0.60 ms
a command substitution  0.51 ms   (for comparison)
starting any process    1.6  ms   (for comparison)

zsh is the cheap one because nothing on its path forks: the command line comes from preexec and is split by parameter expansion. fish pays for two hooks, one to rewrite the line at enter and one to count it afterwards, but both are builtins. bash has no preexec at all, so the only way to see what you typed is $(history 1), and bash forks for a command substitution — that fork is almost the whole 0.60 ms and no amount of tuning removes it.

When a correction is actually needed, 2000 learned commands and 4302 executables on PATH put the whole run at 2.8-3.3 ms, against 2.3 ms for the same binary printing its version. Under a millisecond of that is zcomplete's own work.

zcomplete import is the one command you wait on: about 0.7 s for a 20,000-line history. It runs once.

Disk

Everything sits in one directory you can delete at any time (~/.local/share/zcomplete, or $XDG_DATA_HOME/zcomplete). Every file in it has a ceiling:

filewhat it isbound
commands.binthe database~160 KB, 4096 scoped rows and 512 shortcuts, weakest evicted
path.<hash>the names on one PATHthe 4 most recently used
journal.<pid>what one shell hasn't folded yetone per live shell, emptied at each fold
commands.corrupt.*a database that wouldn't read, kept in case you want itthe 2 most recent

Twelve different PATHs and 2000 learned commands come to 48 KB.

The database holds command names. Never arguments. It's mode 0600 in a directory created 0700.

Settings

No config file. The mode lives in the database, so zcomplete safe reaches open shells and there's no second file to keep in step.

variable
ZCOMPLETE_MODEoverride the mode for one command
ZCOMPLETE_DISABLEswitch corrections off for one shell
ZCOMPLETE_DATA_DIRmove the database
NO_COLOR, TERMcolour
HISTFILE, XDG_DATA_HOMEread by import, honoured for the data path

Shell differences

zsh and bash correct an alias a moment later than a program. Both run the not-found handler in a forked child, which loses what an alias or function does to the shell itself (cd, set, export). When the answer is one of those, zcomplete hands it back for the next prompt to run in the real shell. Looks the same, costs one extra call on a command that had already failed.

Your prompt sees the word you typed, not the command that ran. The fork can't tell the shell it corrected anything. Usually invisible, but a prompt that reacts to specific commands reacts to the typo. Under powerlevel10k a corrected clea leaves the blank line a typed clear would have suppressed; POWERLEVEL9K_PROMPT_ADD_NEWLINE=false removes it.

fish corrects the line before it runs, so zcomplete binds enter there. fish calls fish_command_not_found only after abandoning the job, so correcting from inside it would hand cat the keyboard in printf x | ct. Rewriting the line first keeps pipes, redirections, job control and $status normal. zcomplete chains onto whatever enter was already bound to, so load it last.

bash 3.2, the version macOS ships, can't intercept anything. command_not_found_handle arrived in bash 4.0. On 3.2 zcomplete still learns, and offers the fix at the next prompt instead:

$ mkd build
bash: mkd: command not found
zcomplete: run mkdir build? [Y/n] y

That runs in the real shell, so cd works. Install bash 4+ for in-place correction.

Update

Run the install command again. It replaces the binary and leaves your database, mode and shell config alone.

curl -fsSL https://raw.githubusercontent.com/omarfakih1/zcomplete/main/install.sh | sh

The new binary is written beside the old one and renamed over it, so a shell mid-command keeps running the version it started with. Open shells use the old one until the hook next starts a process. exec $SHELL picks up the new one straight away, and is needed anyway if the integration script itself changed:

zcomplete --version && exec $SHELL

From a checkout: git pull && ./install.sh && exec $SHELL. --version=vX.Y.Z installs a specific release, which is also how you go back.

Uninstall

curl -fsSL https://raw.githubusercontent.com/omarfakih1/zcomplete/main/uninstall.sh | sh

Takes the lines back out of your shell config (leaving a .zcomplete.bak beside each), deletes the database, removes the binary. --keep-data keeps what it learned. Shells you already have open keep the hook until you exec $SHELL.

Tests

cargo test doesn't cover src/init/. Those are tested by driving a real zsh, bash and fish under a pty, which needs all three installed and takes about a minute:

cargo test && cargo build --release && python3 tests/shells.py

tests/bench.sh times the hot path against /usr/bin/true.

License

MIT.

Contributors

omarfakih1

32 commits

Copilot

1 commits

omarfakih1/zcomplete

Mistype a command and zcomplete runs the one you meant, worked out from the commands you usually run. zsh, bash and fish.

31

stars

33

commits

Rust

primary language

Aug 25, 2026

updated

README

zcomplete

You mistype a command. Instead of command not found, zcomplete figures out which command you meant from the ones you actually run, and offers to run it with your arguments.

$ mkd build
zcomplete: run mkdir instead of 'mkd'? [Y/n] y

$ gti status
zcomplete: run git instead of 'gti'? [Y/n] y
On branch main
nothing to commit, working tree clean

$ cargo tset
error: no such command: `tset`
zcomplete: run cargo test? [Y/n] y

zsh, bash and fish. One binary, one dependency (libc), about 550 KB.

Install

curl -fsSL https://raw.githubusercontent.com/omarfakih1/zcomplete/main/install.sh | sh

That downloads the binary, sets up each shell it finds, and offers to seed the database from your history. It asks before touching any config file. -s -- -y answers yes to everything.

Then restart your shell, or exec $SHELL. Done.

The installer checks the binary against its published sha256 and refuses to install if the checksum is missing or wrong. macOS and Linux, arm64 and x86-64. --prefix=DIR defaults to ~/.local. Replace | sh with | less to read install.sh first.

From cargo

cargo install --git https://github.com/omarfakih1/zcomplete
zcomplete init --all && zcomplete import

init --all adds one line to the config of every shell you have. Run it twice and the second run does nothing. It appends and never rewrites, so the rest of your config is untouched. --zsh, --bash or --fish picks one.

import reads your history file and asks the shell for its aliases, functions and builtins. Those aren't on PATH, so without it they'd be discarded as words that aren't commands.

The line init writes, if you'd rather add it yourself:

shelllinefile
zsheval "$(zcomplete init zsh)"~/.zshrc
basheval "$(zcomplete init bash)"~/.bashrc
fishzcomplete init fish | source~/.config/fish/config.fish

(zcomplete init zsh without the dashes prints the integration script. That's what the line above runs. You don't type it yourself.)

zcomplete doctor says what's set up and what isn't.

At the prompt

key
y or Enterrun it
n or ileave it alone
ufix the subcommand too, when one is offered
1-9pick from the list when the match is unclear

One keypress, no Enter. u is for a line where both words are wrong:

$ zcom he
zcomplete: run zcomplete instead of 'zcom'?  u: also he -> help [Y/n] u
zcomplete 0.1.2 - run the command you meant

y there would run zcomplete he and let it fail.

Modes

zcomplete safe      # confirm every correction (default)
zcomplete unsafe    # run ordinary corrections, confirm dangerous ones
zcomplete bypass    # never confirm
zcomplete off       # stop correcting

The mode is read on every correction, so a change reaches shells that are already open. ZCOMPLETE_MODE=safe ./script.sh overrides it for one command.

Dangerous means listed in src/safety.rs: rm, dd, mkfs*, git push --force, git reset --hard, terraform destroy, kubectl delete, recursive chmod, curl ... | sh, and about thirty more. Flags are read the way the shell reads them, so -rf, -r -f, --force and a -- terminator all count, and git clean -n is not treated like git clean -fd.

What it learns

Every command you run gets a rank, and ranks decay with age:

score = rank × 4      used within the hour
        rank × 2      within the day
        rank ÷ 2      within the week
        rank ÷ 4      older

Commands you ran in the current directory count for more, so ma gives make inside a project and man everywhere else.

A typed word is matched four ways: prefix (mkdmkdir), initials (dcdocker-compose), subsequence (dkrdocker) and typo (gtigit, slls). Case is ignored throughout, so a stuck shift key (GIT) resolves like anything else. Match quality decides the winner and usage only breaks ties. Sorting the other way round makes gti mean gtimeout.

Two things hold in every mode, bypass included:

  • Nothing is suggested that isn't a command. A word enters the database only if the shell could resolve it at the time, and every candidate is checked against PATH again before you're offered it.
  • Non-interactive shells are left alone. No controlling terminal means no correction, so scripts and CI never get rewritten.

Subcommands

git sttaus, cargo tset, docker psu. No list of tools is hardcoded. Second words get counted, and a command needs two of them to come back before its second argument is read as a verb at all. So git status and git commit qualify git within a day, and the foo in grep foo file.c never qualifies grep. For a command it knows nothing about, zcomplete reads its --help once and caches the answer, which is why git sttaus works on a fresh install.

A subcommand can't be checked against PATH ahead of time, so it's corrected after the command fails rather than instead of running it. That's safe because a command that failed on its verb did nothing. A line that failed for any other reason is left alone, and a subcommand is only learned once it has exited zero, so git sttaus can never teach zcomplete that git has a sttaus.

Two limits: one command per line (cp a b && git sttaus is refused, since rerunning would repeat the copy), and the first two words only (npm run buidl is out of reach).

Answers

Confirm mkdmkdir three times and it becomes the direct answer. Refuse the same suggestion twice and it retires. Or set it yourself:

zcomplete bind gs git        # gs always means git
zcomplete unbind gs
zcomplete ignore sl          # never suggest sl

Commands

zcomplete init --all             set up every shell you have (--zsh, --bash, --fish)
zcomplete stats [-n N]           what it has learned, strongest first
zcomplete stats <command>        subcommands it would offer for one command
zcomplete query <word>           what a word would resolve to (--score for detail)
zcomplete query <cmd> <word>     the same, for a subcommand
zcomplete import [zsh|bash|fish] seed from history (--dry-run to preview)
zcomplete forget <command>...    unlearn; --all empties everything
zcomplete ignore [<command>...]  list, add to, or --remove from the ignore list
zcomplete bind <word> <command>  pin a shortcut; unbind removes it
zcomplete mode                   show the current mode
zcomplete safe | unsafe | bypass set it
zcomplete on | off               enable or disable without editing shell config
zcomplete doctor                 check the installation

Speed

A command that worked needs no correction, so nothing starts for it. The hook appends one line to a per-session file. Per command, on an M-series Mac:

zsh                     0.07 ms
fish                    0.21 ms
bash                    0.60 ms
a command substitution  0.51 ms   (for comparison)
starting any process    1.6  ms   (for comparison)

zsh is the cheap one because nothing on its path forks: the command line comes from preexec and is split by parameter expansion. fish pays for two hooks, one to rewrite the line at enter and one to count it afterwards, but both are builtins. bash has no preexec at all, so the only way to see what you typed is $(history 1), and bash forks for a command substitution — that fork is almost the whole 0.60 ms and no amount of tuning removes it.

When a correction is actually needed, 2000 learned commands and 4302 executables on PATH put the whole run at 2.8-3.3 ms, against 2.3 ms for the same binary printing its version. Under a millisecond of that is zcomplete's own work.

zcomplete import is the one command you wait on: about 0.7 s for a 20,000-line history. It runs once.

Disk

Everything sits in one directory you can delete at any time (~/.local/share/zcomplete, or $XDG_DATA_HOME/zcomplete). Every file in it has a ceiling:

filewhat it isbound
commands.binthe database~160 KB, 4096 scoped rows and 512 shortcuts, weakest evicted
path.<hash>the names on one PATHthe 4 most recently used
journal.<pid>what one shell hasn't folded yetone per live shell, emptied at each fold
commands.corrupt.*a database that wouldn't read, kept in case you want itthe 2 most recent

Twelve different PATHs and 2000 learned commands come to 48 KB.

The database holds command names. Never arguments. It's mode 0600 in a directory created 0700.

Settings

No config file. The mode lives in the database, so zcomplete safe reaches open shells and there's no second file to keep in step.

variable
ZCOMPLETE_MODEoverride the mode for one command
ZCOMPLETE_DISABLEswitch corrections off for one shell
ZCOMPLETE_DATA_DIRmove the database
NO_COLOR, TERMcolour
HISTFILE, XDG_DATA_HOMEread by import, honoured for the data path

Shell differences

zsh and bash correct an alias a moment later than a program. Both run the not-found handler in a forked child, which loses what an alias or function does to the shell itself (cd, set, export). When the answer is one of those, zcomplete hands it back for the next prompt to run in the real shell. Looks the same, costs one extra call on a command that had already failed.

Your prompt sees the word you typed, not the command that ran. The fork can't tell the shell it corrected anything. Usually invisible, but a prompt that reacts to specific commands reacts to the typo. Under powerlevel10k a corrected clea leaves the blank line a typed clear would have suppressed; POWERLEVEL9K_PROMPT_ADD_NEWLINE=false removes it.

fish corrects the line before it runs, so zcomplete binds enter there. fish calls fish_command_not_found only after abandoning the job, so correcting from inside it would hand cat the keyboard in printf x | ct. Rewriting the line first keeps pipes, redirections, job control and $status normal. zcomplete chains onto whatever enter was already bound to, so load it last.

bash 3.2, the version macOS ships, can't intercept anything. command_not_found_handle arrived in bash 4.0. On 3.2 zcomplete still learns, and offers the fix at the next prompt instead:

$ mkd build
bash: mkd: command not found
zcomplete: run mkdir build? [Y/n] y

That runs in the real shell, so cd works. Install bash 4+ for in-place correction.

Update

Run the install command again. It replaces the binary and leaves your database, mode and shell config alone.

curl -fsSL https://raw.githubusercontent.com/omarfakih1/zcomplete/main/install.sh | sh

The new binary is written beside the old one and renamed over it, so a shell mid-command keeps running the version it started with. Open shells use the old one until the hook next starts a process. exec $SHELL picks up the new one straight away, and is needed anyway if the integration script itself changed:

zcomplete --version && exec $SHELL

From a checkout: git pull && ./install.sh && exec $SHELL. --version=vX.Y.Z installs a specific release, which is also how you go back.

Uninstall

curl -fsSL https://raw.githubusercontent.com/omarfakih1/zcomplete/main/uninstall.sh | sh

Takes the lines back out of your shell config (leaving a .zcomplete.bak beside each), deletes the database, removes the binary. --keep-data keeps what it learned. Shells you already have open keep the hook until you exec $SHELL.

Tests

cargo test doesn't cover src/init/. Those are tested by driving a real zsh, bash and fish under a pty, which needs all three installed and takes about a minute:

cargo test && cargo build --release && python3 tests/shells.py

tests/bench.sh times the hot path against /usr/bin/true.

License

MIT.

See what people are saying

Contributors

omarfakih1

32 commits

Copilot

1 commits

Languages

Rust

73.2%

Shell

13.4%

Python

13.4%