The complete source-derived reference for engineers, packagers, security reviewers, and teams shipping The Fuck at scale or extending it. The User Guide covers daily usage; this document covers architecture, internals, the rule plugin protocol, and the operational surface.
1. Architecture Overview
What it is
The Fuck (thefuck 3.32 by Vladimir Iakovlev, MIT license) is a Python CLI that reads the most recently failed shell command, runs it through an in-process pipeline of pluggable correction rules, and emits a corrected command on stdout. A small shell function — installed via eval $(thefuck --alias) — captures shell history, invokes the binary, and evals its output. The corrected command can also be written back into the shell history so the up-arrow recall is the fix rather than the typo.
There is no daemon, no server, no socket (with one optional exception covered below). Everything is single-process, single-shot, no persistent state beyond a small XDG cache.
High-level component diagram
┌──────────────────────────────────────────────┐
│ INTERACTIVE SHELL │
│ (Bash / Zsh / Fish / Tcsh / PowerShell) │
│ │
│ fuck() ◄── shell function/alias │
│ │ │
│ │ exports TF_HISTORY, TF_SHELL, │
│ │ TF_SHELL_ALIASES, PYTHONIOENCODING │
│ ▼ │
│ thefuck (Python binary) │
└──────────────────────────────────────────────┘
│ stdout
│ (a single corrected command string)
▼
eval
┌────────────────────────────────────────────────────┐
│ thefuck.entrypoints.main │
│ ───────────────────────────────────────────────── │
│ Parser → branch on -v / -a / -l / command │
│ │
│ -a → alias.print_alias (Section 4) │
│ -l → shell_logger.shell_logger │
│ cmd → fix_command.fix_command │
└────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ thefuck.entrypoints.fix_command │
│ ────────────────────────────────────────────────────────────────────── │
│ 1. settings.init(args) ← merge file + env + flags │
│ 2. raw = _get_raw_command(args) ← from arg, TF_HISTORY, fc │
│ 3. Command.from_raw_script(raw) ← shell.from_shell + output │
│ 4. corrected = get_corrected_commands() ← rule pipeline │
│ 5. selected = select_command(corrected) ← arrow-key UI │
│ 6. selected.run(command) ← side_effect + history │
└─────────────────────────────────────────────────────────────────────────┘
│
┌───────────────────────────┼─────────────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────────────┐ ┌──────────────────────┐
│ thefuck.shells │ │ thefuck.output_readers │ │ thefuck.corrector │
│ (.bash, .zsh, │ │ (.rerun ← default) │ │ (rule loader, │
│ .fish, .tcsh, │ │ (.read_log ← instant) │ │ sort, dedup) │
│ .powershell, │ │ (.shell_logger ← socket) │ │ │
│ .generic) │ └──────────────────────────┘ └──────────────────────┘
└──────────────────┘ │ │
▼ ▼
(the failing command) thefuck.rules.* (169 files)
is re-executed and + ~/.config/thefuck/rules/*
output captured + thefuck_contrib_*/rules/*
Note
Every box above is an actual Python module under
thefuck/with the matching path. There is no dynamic dispatch beyond stdlibimportlib.util.spec_from_file_locationfor loading rule files. The "shell" object is selected once at startup by walking the parent process tree (psutil.Process(os.getpid()).parent()) until a known name is found.
Component inventory
| Component | Path | Role |
|---|---|---|
| Argument parser | thefuck/argument_parser.py |
Defines the public CLI surface, handles the THEFUCK_ARGUMENT_PLACEHOLDER trick that lets the alias pass arbitrary commands without conflicting with thefuck's own flags |
| Settings | thefuck/conf.py |
The Settings(dict) class with attribute access. Loads defaults, then ~/.config/thefuck/settings.py, then env vars, then args |
| Constants | thefuck/const.py |
DEFAULT_SETTINGS, ENV_TO_ATTR mapping, key codes, sentinel objects, log size limits, the USER_COMMAND_MARK (U+200B × 10) used by instant mode |
| Corrector | thefuck/corrector.py |
Walks rule import paths, loads rules, sorts by priority, yields unique CorrectedCommands |
| Types | thefuck/types.py |
Command, Rule, CorrectedCommand — the three load-bearing dataclasses |
| Exceptions | thefuck/exceptions.py |
EmptyCommand, NoRuleMatched, ScriptNotInLog |
| Logging | thefuck/logs.py |
colorama-based stderr formatting, debug timing context manager, "how to configure alias" prints |
| UI | thefuck/ui.py |
read_actions() keypress loop, CommandSelector lazy iterator, select_command() — the up/down/enter prompt |
| Utilities | thefuck/utils.py |
memoize, cache, which, get_close_matches, get_all_executables, the Cache class wrapping shelve |
| System | thefuck/system/ |
Platform-split: unix.py and win32.py, plus __init__.py that picks one. Provides Path, init_output, get_key, open_command |
| Argument entry | thefuck/entrypoints/main.py |
The setuptools console_scripts entry. Imports system.init_output first to set up colorama |
| Alias entry | thefuck/entrypoints/alias.py |
Calls shell.app_alias() and prints |
| Fix entry | thefuck/entrypoints/fix_command.py |
The pipeline orchestrator |
| Not-configured entry | thefuck/entrypoints/not_configured.py |
The "how to configure" splash + auto-write-on-second-run logic. Maps to the fuck binary on Linux/macOS and thefuck_firstuse on Windows |
| Shell-logger entry | thefuck/entrypoints/shell_logger.py |
The PTY recorder used by experimental instant mode |
| Shell adapters | thefuck/shells/ |
Generic, Bash, Zsh, Fish, Tcsh, Powershell — each implements app_alias(), get_aliases(), from_shell(), to_shell(), how_to_configure(), _get_history_file_name() |
| Output readers | thefuck/output_readers/ |
rerun.py (default, re-executes the script), read_log.py (instant mode, reads from THEFUCK_OUTPUT_LOG), shell_logger.py (Unix socket integration) |
| Rule package | thefuck/rules/ |
169 rule modules, one per correction pattern |
| Specific helpers | thefuck/specific/ |
Per-tool helpers: apt_available, archlinux_env, brew_available/get_brew_path_prefix, dnf_available, git_support decorator, nix_available, npm_available/get_scripts, sudo_support decorator, yum_available |
Technology stack
| Layer | Tool / library | Purpose |
|---|---|---|
| Language | Python ≥2.7 (3.5+ recommended) | Single source-tree supports both via six shims |
| Process inspection | psutil |
Walking parent process tree for shell detection; killing the rerun child cleanly on timeout |
| ANSI / Windows colors | colorama (+ win_unicode_console on Win) |
Cross-platform colored stderr |
| Compatibility shims | six, pathlib2 (Py2), decorator |
Decorator factories that work in 2/3, Path polyfill |
| Optional | pyte |
Terminal emulator used by instant mode and the shell-logger reader to reconstruct line-wrapped output |
| Build | setuptools, fastentrypoints |
A vendored fastentrypoints.py patches setuptools so console scripts skip the slow pkg_resources import |
| Tests | pytest, mock, pytest-mock, pytest-docker-pexpect |
Unit + functional matrix that boots a shell in a Docker container |
Data flow — what happens when you type fuck
- The alias function (a few lines of Bash/Zsh/Fish/Tcsh/PowerShell shipped by
shell.app_alias()) saves$PYTHONIOENCODING, exportsTF_PYTHONIOENCODINGfor restore, exportsTF_SHELL,TF_ALIAS,TF_SHELL_ALIASES(Bash/Zsh from$(alias)),TF_HISTORY(Bash/Zsh from$(fc -ln -10); Fish from$history[1]; PowerShell fromGet-History -Count 1), forcesPYTHONIOENCODING=utf-8, then runsthefuck $ARG_PLACEHOLDER "$@"in command substitution andevals the result. thefuck/entrypoints/main.pyinitialises colorama viasystem.init_output()before importing anything else (becausecolorama.initpatches stdout on Windows and other modules might already write to it). Then it constructsParser, parsessys.argv, and dispatches toprint_alias,fix_command, orshell_logger.fix_command(thefuck/entrypoints/fix_command.py) callssettings.init(args)— this:- creates
~/.config/thefuck/and~/.config/thefuck/rules/if needed (legacy~/.thefuck/is honoured with aDeprecationWarning). - creates
settings.pyfromconst.SETTINGS_HEADERif missing. - imports
settings.pyviaimportlib.utiland copies any keys fromDEFAULT_SETTINGS.keys(). - reads each
THEFUCK_*env var listed inconst.ENV_TO_ATTRand converts via_val_from_env(string → int/bool/list/dict). - applies
args.yes,args.debug,args.repeatoverrides.
- creates
- Raw command extraction (
_get_raw_command):- if
--force-commandwas passed (used by--repeat), use that string verbatim. - else if
TF_HISTORYis in env (alias path), reverse the history, drop entries that are themselves the alias (using a 0.5 SequenceMatcher cutoff vsTF_ALIAS), and return the first non-alias line. - else use the positional
commandargs verbatim.
- if
Command.from_raw_scriptjoins the raw parts to a script, runsshell.from_shell()to expand any shell aliases (Bash/Zsh: parseTF_SHELL_ALIASES; Fish: invokefish -ic aliasandfish -ic functions; Tcsh: invoketcsh -ic alias), and callsoutput_readers.get_output().- Output reading (
thefuck/output_readers/__init__.py) picks one path:- if
SHELL_LOGGER_SOCKETenv var points at an existing socket → ask it for the last 5 commands' output. - else if
settings.instant_modeis True → read fromTHEFUCK_OUTPUT_LOGmmap, group byUSER_COMMAND_MARK/PS1, and feed throughpyte.Screento reconstruct the terminal display. - else →
Popen(expanded, shell=True, ..., env=os.environ + settings.env)and waitwait_command(orwait_slow_commandif the binary is inslow_commands). On timeout, kill the entire process tree (children first viapsutil.Process(pid).children(recursive=True)).
- if
- Rule pipeline (
thefuck.corrector.get_corrected_commands):get_rules_import_paths()yieldsthefuck/rules/,~/.config/thefuck/rules/, and everythefuck_contrib_*/rules/directory found anywhere onsys.path.get_rules()globs*.py(skipping__init__.py), passes each toRule.from_pathwhich loads it viaimportlib.util.spec_from_file_location(or legacyimp.load_sourceon Python <3.5), and readsmatch,get_new_command,enabled_by_default,side_effect,priority,requires_outputoff the module.- rules are filtered by
is_enabled(name in settings.rulesOR (enabled_by_defaultANDALL_ENABLED in settings.rules)) ANDname not in settings.exclude_rules. - rules are sorted ascending by priority (default
DEFAULT_PRIORITY = 1000, overridden per-rule viapriority = Nand per-user viasettings.priority['rule_name'] = N). - for each rule,
is_match(command)is called inside adebug_timecontext. If the rule throws, it logs the traceback and continues (no rule failure ever crashes the pipeline). - matching rules'
get_new_commandresults are wrapped inCorrectedCommand(one per result string; if the rule returns a list, each gets its ownCorrectedCommandwith priority scaled by(n+1) * rule.priority). organize_commandsdeduplicates (by script and side_effect) and sorts again.
- Selector UI (
thefuck/ui.py):CommandSelectoris a lazy iterator — it pulls the first command eagerly to detect "no fucks given", but the rest are realised only when the user presses ↑/↓.read_actions()blocks onsystem.get_key()(raw termios on Unix,msvcrt.getwchon Windows) and yieldsACTION_*constants.- if
require_confirmationis False, the first command is auto-selected.
- Run (
CorrectedCommand.run):- if a
side_effectis defined, call it with(old_cmd, new_script)BEFORE printing. - call
shell.put_to_history(script)(Fish writes a literal entry; Bash/Zsh/Tcsh defer to the shell function'shistory -s/print -sline). - if
settings.repeatis True, wrap the script inshell.or_(script, "fuck --repeat --force-command <quoted-original>")so a failure re-runs the loop. - finally, print to stdout. The shell alias
evals that.
- if a
Important
The architectural invariant: The Fuck never executes the corrected command itself. It writes to stdout; the shell function's
evalis what runs it. This means corrections execute in the user's interactive shell — samecwd, same env, same shell options — and the rerun used for capturing output is a different subprocess that does not see the side effects.
2. System Requirements
Runtime
| Layer | Minimum | Recommended | Notes |
|---|---|---|---|
| Python | 2.7 or 3.5 | 3.10–3.11 | Python 2 is deprecated; 3.12+ has known issues with imp and distutils (issues #1434/#1444). 3.13 issues with imp are tracked in #1527. |
| pip | 6.0 | latest | Hard check in setup.py:11. |
psutil |
any | latest | Used for parent-process walking and process kill. |
colorama |
any | latest | Pure Python; works everywhere. |
six |
any | latest | Py2/Py3 compatibility shim. |
pyte |
optional | latest | Required for instant mode and shell-logger reader. |
decorator |
any | latest | Used to compose @for_app, @sudo_support, @git_support, @cache, @memoize, @eager. |
pathlib2 |
optional | — | Auto-installed for Python <3.4 only. |
backports.shutil_get_terminal_size |
optional | — | Auto-installed for Python <3.3 only. |
win_unicode_console |
optional | — | Auto-installed on win32 only. |
Shells
| Shell | Min version tested | Alias source | History capture |
|---|---|---|---|
| Bash | 3.x | BASH_VERSION via bash -c 'echo $BASH_VERSION' |
fc -ln -10 |
| Zsh | 4+ | ZSH_VERSION via zsh -c 'echo $ZSH_VERSION' |
fc -ln -10 |
| Fish | 2+ | fish --version |
$history[1] plus ~/.config/fish/fish_history |
| Tcsh / Csh | 6+ | tcsh --version |
history -h 2 | head -n 1 |
| PowerShell | 5+ / pwsh | $PSVersionTable.PSVersion or pwsh --version |
Get-History -Count 1 |
| Generic | — | — | Falls back to alias-on-tty pattern |
OS / platform
| Platform | Status |
|---|---|
| Linux (any distro) | Fully supported. |
| macOS | Fully supported. |
| FreeBSD / OpenBSD | Supported via pkg/ports. --shell-logger works. |
| Windows | Supported. Drops --shell-logger and instant mode. Ships scripts/fuck.bat and scripts/fuck.ps1. |
| WSL | Works; you may need TF_SHELL=bash if process-tree detection lands on init. |
| Termux (Android) | Works; PATH adjustments may be needed (issue #1369). |
| Docker / CI | Works in non-interactive mode with THEFUCK_REQUIRE_CONFIRMATION=false. |
Optional system tools
| Tool | Used by | Notes |
|---|---|---|
script(1) |
instant mode | bsdutils (Debian/Ubuntu), util-linux (Arch), built-in (macOS). |
lsof |
port_already_in_use rule |
Disabled if missing. |
pkgfile |
pacman, pacman_not_found rules |
Arch Linux only. |
apt-get, dnf, yum, brew, nix |
corresponding rules | Each rule self-disables via <tool>_available if the binary is not on PATH. |
3. Configuration Reference
Configuration files
| File | Format | Default location | Created by |
|---|---|---|---|
settings.py |
Python source | ${XDG_CONFIG_HOME:-~/.config}/thefuck/settings.py |
Auto-generated on first run from const.SETTINGS_HEADER |
| User rules | Python source | ${XDG_CONFIG_HOME:-~/.config}/thefuck/rules/*.py |
Manually created — directory pre-created on first run |
| Shelve cache | shelve (dbm) |
${XDG_CACHE_HOME:-~/.cache}/thefuck |
Auto-created by Cache._setup_db |
| Legacy config | Python source | ~/.thefuck/settings.py and ~/.thefuck/rules/ |
Used only if it pre-exists; printed deprecation warning |
The complete settings dictionary
Defined in thefuck/const.py. Every key has a default; every key is overridable via env var listed in ENV_TO_ATTR.
| Setting | Type | Default | Purpose |
|---|---|---|---|
rules |
list[str] |
[ALL_ENABLED] |
Rule whitelist. Use the sentinel ALL_ENABLED (env: literal string DEFAULT_RULES) to mean "every rule whose enabled_by_default is True". |
exclude_rules |
list[str] |
[] |
Rules to skip (subtractive — runs even with ALL_ENABLED). |
wait_command |
int (s) |
3 |
Subprocess timeout for the rerun. |
wait_slow_command |
int (s) |
15 |
Subprocess timeout when the rerun's first arg is in slow_commands. |
slow_commands |
list[str] |
['lein', 'react-native', 'gradle', './gradlew', 'vagrant'] |
Binaries that get the longer rerun timeout. |
require_confirmation |
bool |
True |
If False, auto-runs the first suggestion. |
no_colors |
bool |
False |
Disables colorama escape sequences. |
debug |
bool |
False |
Emits [DEBUG] lines and debug_time durations to stderr. |
priority |
dict[str,int] |
{} |
Per-rule priority override. Lower value = earlier. |
history_limit |
int | None |
None |
Caps how many lines of history are read by _get_history_lines. |
alter_history |
bool |
True |
Push the corrected command into shell history. |
repeat |
bool |
False |
Wrap the corrected command so a failure re-runs fuck. |
instant_mode |
bool |
False |
Use the shell-logger output reader instead of the rerun. |
num_close_matches |
int |
3 |
How many suggestions to ask difflib.get_close_matches for. |
env |
dict[str,str] |
{'LC_ALL': 'C', 'LANG': 'C', 'GIT_TRACE': '1'} |
Env vars added to the rerun subprocess. |
excluded_search_path_prefixes |
list[str] |
[] |
PATH directories to skip when listing executables (avoids slow NFS mounts). |
fixlinecmd |
str |
'{editor} {file} +{line}' |
Used by fix_file rule. Loaded only when that rule runs. |
fixcolcmd |
str | None |
None |
Optional fix_file template that includes column position. |
user_dir |
Path |
~/.config/thefuck |
Resolved at init by _get_user_dir_path. Honours XDG_CONFIG_HOME. |
Environment variable map
The exact mapping in const.ENV_TO_ATTR:
ENV_TO_ATTR = {
'THEFUCK_RULES': 'rules',
'THEFUCK_EXCLUDE_RULES': 'exclude_rules',
'THEFUCK_WAIT_COMMAND': 'wait_command',
'THEFUCK_REQUIRE_CONFIRMATION': 'require_confirmation',
'THEFUCK_NO_COLORS': 'no_colors',
'THEFUCK_DEBUG': 'debug',
'THEFUCK_PRIORITY': 'priority',
'THEFUCK_HISTORY_LIMIT': 'history_limit',
'THEFUCK_ALTER_HISTORY': 'alter_history',
'THEFUCK_WAIT_SLOW_COMMAND': 'wait_slow_command',
'THEFUCK_SLOW_COMMANDS': 'slow_commands',
'THEFUCK_REPEAT': 'repeat',
'THEFUCK_INSTANT_MODE': 'instant_mode',
'THEFUCK_NUM_CLOSE_MATCHES': 'num_close_matches',
'THEFUCK_EXCLUDED_SEARCH_PATH_PREFIXES': 'excluded_search_path_prefixes',
}
Settings._val_from_env parses values:
| Setting | Parse rule |
|---|---|
rules, exclude_rules |
Split on :. The string DEFAULT_RULES expands to [ALL_ENABLED]. |
priority |
Split on :, then split each on =. Drops malformed pairs silently. |
wait_command, history_limit, wait_slow_command, num_close_matches |
int(val) |
require_confirmation, no_colors, debug, alter_history, instant_mode |
val.lower() == 'true' |
slow_commands, excluded_search_path_prefixes |
Split on :. |
| Anything else | passed through as string. |
Important
Changing
settings.pyrequires no restart — settings are read fresh on every invocation. Changing the alias (re-runningeval $(thefuck --alias)) does require sourcing your shell rc again because the alias is a function in your current shell.
CLI flag reference (full)
| Flag | dest | action | Default | Mutex group |
|---|---|---|---|---|
-v, --version |
version |
store_true |
False | |
-a, --alias [NAME] |
alias |
nargs=?, const=get_alias() |
None |
|
-l PATH, --shell-logger PATH |
shell_logger |
store |
None |
|
--enable-experimental-instant-mode |
enable_experimental_instant_mode |
store_true |
False | |
-h, --help |
help |
store_true |
False | |
-d, --debug |
debug |
store_true |
False | |
--force-command CMD |
force_command |
store (suppressed in --help) |
None |
|
command |
command |
nargs='*' |
[] |
|
-y, --yes, --yeah, --hard |
yes |
store_true |
False | yes/repeat |
-r, --repeat |
repeat |
store_true |
False | yes/repeat |
The argument prep step in Parser._prepare_arguments rewrites argv so The Fuck's flags are parsed first, then -- separates them from the user's command. The placeholder THEFUCK_ARGUMENT_PLACEHOLDER is used inside the alias to mark the boundary.
Annotated walkthroughs
Workplace-friendly setup
# ~/.config/thefuck/settings.py
rules = ['DEFAULT_RULES'] # equivalent to ALL_ENABLED
exclude_rules = ['rm_root'] # belt-and-braces — already off by default
# Auto-confirm only when run with -y; keep prompts otherwise
require_confirmation = True
# Don't write fixed commands into shell history
alter_history = False
# Enterprise NFS mount that we don't want to scan for executables
excluded_search_path_prefixes = ['/mnt/corp-share', '/var/empty']
# Stricter close-match cutoff via priority
priority = {
'sudo': 5000, # try sudo only after most other rules
'history': 9999, # last resort
}
Performance-tuned setup for a slow-shell environment
# Limit imports to a small whitelist for snappy first-call
rules = [
'cd_correction', 'cd_mkdir', 'cd_parent',
'git_push', 'git_pull', 'git_add', 'git_branch_delete',
'mkdir_p', 'sudo', 'no_command',
]
# Don't memoize — for a stateless container
# (memoize.disabled is set elsewhere; cache.disabled via env)
# Bigger rerun budget for builds
wait_slow_command = 60
slow_commands = ['gradle', './gradlew', 'mvn', 'lein', 'react-native', 'vagrant', 'docker-compose', 'sbt']
export THEFUCK_NUM_CLOSE_MATCHES=5
Instant-mode setup
# In ~/.zshrc, MUST be the last line
eval "$(thefuck --alias --enable-experimental-instant-mode)"
# ~/.config/thefuck/settings.py
instant_mode = True
instant_mode = True makes the output reader prefer read_log over rerun. The alias adds:
export THEFUCK_INSTANT_MODE=True
export THEFUCK_OUTPUT_LOG=/tmp/thefuck-script-log-<uuid>
thefuck --shell-logger $THEFUCK_OUTPUT_LOG
rm $THEFUCK_OUTPUT_LOG
exit
Effectively the alias replaces the user's shell with a script(1)-equivalent recorder. When the user invokes fuck, the engine reads from the mmap'd log instead of re-running.
Warning
Instant mode permanently alters your
PS1for the lifetime of the shell. If you have a prompt theme that re-setsPS1on every command, instant mode silently breaks (you'll see[WARN] PS1 doesn't contain user command markon first invocation).
4. Deployment & Scaling
Distribution channels
| Channel | Recipe |
|---|---|
| PyPI | pip install thefuck (built from setup.py via python setup.py sdist bdist_wheel && twine upload dist/* — see release.py). |
| Homebrew | brew install thefuck — formula tracks the GitHub release tag. |
| Snap | snapcraft.yaml defines name: thefuck, classic confinement, exposes both thefuck and fuck apps with PYTHONIOENCODING=utf-8. |
| Debian / Ubuntu | Distro packagers maintain thefuck in the universe repo. |
| Fedora / Arch / FreeBSD | Distro packages exist; track upstream tags. |
| Windows | pip install ships scripts/fuck.bat and scripts/fuck.ps1. The Windows entry-points map thefuck and thefuck_firstuse (NOT fuck, because fuck.exe would clash). |
Building from source
git clone https://github.com/nvbn/thefuck.git
cd thefuck
pip install -r requirements.txt
pip install .
pytest
requirements.txt includes the dev-only deps (flake8, pytest, mock, pytest-mock, pypandoc, pytest-benchmark, pytest-docker-pexpect, twine, wheel, setuptools, pexpect).
Release process
release.py:
- Reads
setup.py, findsVERSION = 'X.Y', increments minor. - Writes back.
git pull,git commit -am "Bump to X.Y+1",git tag X.Y+1,git push,git push --tags.rm -rf dist/*,python setup.py sdist bdist_wheel,twine upload dist/*— all withCONVERT_README=truesopypandocconverts the README from md to rst for PyPI.
Note
Setting
CONVERT_README=truemakessetup.pyimportpypandocand convert README.md to RST for the long_description field. Without it,long_descriptionis empty — fine for installs from a git checkout, problematic for PyPI uploads.
Scaling considerations
The Fuck is per-user, per-shell, single-shot. There is nothing to scale at the process level. Multi-user concerns:
- Shared dotfiles repo: Many teams check in their shell rc plus a
~/.config/thefuck/directory. The Fuck handles missing files gracefully; symlinks work fine. - Container images: Pre-install via
pip install thefuckand pre-writesettings.pywithrequire_confirmation=False. This gives one-shot CLI corrections in CI. - Centralised rule packs: Publish a
thefuck_contrib_<team>package on a private PyPI. The corrector auto-discovers anythefuck_contrib_*directory anywhere onsys.pathand globs itsrules/. Versioning the rule pack independently from The Fuck is a clean separation.
Multi-shell deployments
A single user may use multiple shells. Each shell needs its own alias line:
# In ~/.bashrc
[[ -n "$BASH_VERSION" ]] && eval "$(thefuck --alias)"
# In ~/.zshrc
eval $(thefuck --alias)
# In ~/.config/fish/config.fish
thefuck --alias | source
The Fuck detects the active shell at runtime via psutil, so the same Python install handles them all.
5. Operations
Health checks
There is no health endpoint. The closest equivalents:
| Check | Command | Expected output |
|---|---|---|
| Binary present | which thefuck && which fuck |
Two paths under ~/.local/bin (or equivalent) |
| Version | thefuck --version |
The Fuck X.Y using Python A.B.C and {shell info} |
| Alias resolves | thefuck --alias |
Shell function source on stdout |
| Settings load | fuck -d 2>&1 | grep "Run with settings" |
Pretty-printed Settings dict |
| Rule import succeeds | fuck -d 2>&1 | grep "Importing rule" |
One log line per rule |
Tip
For a deployment-time smoke test:
THEFUCK_REQUIRE_CONFIRMATION=false thefuck git brnch 2>/dev/null \ | grep -q "git branch" && echo OK || echo FAILThis bypasses the alias and runs The Fuck against a known input.
Logging
- All non-stdout output is stderr. Wrappers that capture stderr (CI logs, journald, etc.) will see the colored prompt and warnings.
--debug(orTHEFUCK_DEBUG=trueordebug=True) emits:Run with settings: {...}(pretty-printed every-key dump)Importing rule X took: H:MM:SS.dddddd(per rule)Trying rule X took: ...(per match attempt)Call: <script>; with env: {...}; is slow: True/False took: ...(per rerun)Read output from log took: ...(instant mode)Received output: <captured>PYTHONIOENCODING: <value>after every successful runCorrected commands: <pretty list>from the corrector
- Rule failures log a full traceback to stderr but never crash the pipeline.
colorama.Style.RESET_ALLis always emitted between alerts to keep terminal state sane.
Note
The Fuck writes nothing to disk except (a) the auto-generated
settings.py, (b) the shelve cache, (c) thelast_not_configured_run_<user>JSON file intempdir, and (d) the instant-modethefuck-script-log-<uuid>file. All four are removable at any time.
Cache management
Cache.__init__ lazy-opens the shelve. Cache keys are SHA-stable: pickle.dumps((fn.__module__, repr(fn).split('at')[0], depends_on, args, kwargs)). The etag is the dot-joined os.path.getmtime of every depends_on path — so when /usr/local/bin/brew is upgraded, every @cache(which('brew'))(...) entry invalidates automatically.
Manually clear:
rm -rf ${XDG_CACHE_HOME:-~/.cache}/thefuck*
The cache is auto-deleted and re-created on a dbm.error (which fires when you switch Python versions and the dbm format mismatches — issue surfaced via the warning Removing possibly out-dated cache).
Monitoring
There is nothing to monitor — The Fuck does not run as a service. You can monitor the user's experience via shell history pattern matching ("how often does fuck immediately follow a failed command?") if you have shell history aggregation, but there is no built-in telemetry.
Backup / restore
No state to back up. The two user-mutable files are:
| Path | Restore procedure |
|---|---|
~/.config/thefuck/settings.py |
Delete and let it regenerate; or restore from your dotfiles repo. |
~/.config/thefuck/rules/*.py |
Restore from your dotfiles repo. |
The shelve cache and tempfiles are safe to delete at any time.
6. Performance
Startup cost
| Phase | Approximate cost |
|---|---|
| Python interpreter start (no entry-point optimisation) | 50–250 ms |
Python interpreter start (with fastentrypoints.py) |
30–100 ms |
colorama.init + system.init_output |
<5 ms |
Settings.init: read settings.py, env, args |
5–20 ms |
Shell detection (psutil.Process(getpid()).parent().name()) |
5–20 ms |
| First call: rule package imports (169 rules + user + contrib) | 200–500 ms |
| First call: rerun subprocess (default 3-second window) | 0–3000 ms |
First call: rule pipeline (is_match × N rules) |
50–300 ms |
| Total typical first call | 0.5 – 4 seconds |
| Subsequent calls (within shell lifetime; memoize hot) | 0.3 – 2 seconds |
fastentrypoints.py
Vendored from github.com/ninjaaron/fast-entry_points. It monkey-patches setuptools.command.easy_install.ScriptWriter.get_args so console scripts skip pkg_resources import (which is the dominant cost in stock setuptools entry-points). On large sys.path setups (Conda, virtualenvs with many extras), this can shave 100–300 ms per invocation.
Memoization
thefuck.utils.memoize:
def memoize(fn):
memo = {}
@wraps(fn)
def wrapper(*args, **kwargs):
if not memoize.disabled:
key = pickle.dumps((args, kwargs))
if key not in memo:
memo[key] = fn(*args, **kwargs)
value = memo[key]
else:
value = fn(*args, **kwargs)
return value
return wrapper
memoize.disabled = False
A process-lifetime LRU. Used on which, is_app, Bash.get_aliases, Tcsh.get_aliases, Zsh.get_aliases, _get_destination (in path_from_history), _get_executable (in missing_space_before_subcommand), and a dozen other expensive functions. Disable globally with monkeypatch.setattr('thefuck.utils.memoize.disabled', True) (the test suite does this in conftest).
Persistent cache
thefuck.utils.cache(*depends_on) and the underlying Cache class. Used by:
gem_unknown_command._get_all_commands(@cache(which('gem'))) — cachesgem help commandsoutput keyed by the gem binary's mtime.docker_not_command.get_docker_commands(@cache(which('docker'))).go_unknown_command.get_golang_commands.grunt_task_not_found._get_all_tasks(@cache('Gruntfile.js')).gulp_not_task.get_gulp_tasks(@cache('gulpfile.js')).react_native_command_unrecognized._get_commands(@cache('package.json')).yarn_command_not_found._get_all_tasks(@cache(which('yarn'))).omnienv_no_such_command.get_app_commandsper-binary.- Fish:
_get_functions(@cache('~/.config/fish/config.fish', '~/.config/fish/functions')) and_get_aliases.
The cache is only consulted when a rule actually needs the data, so cold-start cost is unaffected.
Slow rerun pitfalls
The engine runs the failed command for up to wait_command seconds (default 3) to capture output. If the failure was a long-running command that crashed near the end, the rerun could complete the long part again. Mitigations:
- Add the binary to
slow_commandsso it getswait_slow_command(15s) instead. - Use instant mode (Bash/Zsh + Linux/macOS) to read from a recorded log instead.
- Provide custom rules with
requires_output = Falseso they skip the rerun entirely.
Tip
For a build tool like
gradle, setrequires_output = Falseon a custom rule and key offcommand.scriptalone. This avoids the 15-second penalty for tools you correct often.
Rule-import time
Each rule is imp.load_source'd (or importlib.util.spec_from_file_location'd on Py3) on every invocation. For 169 rules, this is the largest single cost. Mitigations:
- Whitelist with
rules = [...]to skip imports of irrelevant rules. - Exclude high-cost rules (e.g.,
pacman,pacman_not_foundimportarchlinux.get_pkgfilewhich can shell out at module load time on non-Arch systems). excluded_search_path_prefixesis honoured byget_all_executables— listing a slow NFS mount there saves time per invocation, not per rule.
7. Security
Threat model
The Fuck has elevated trust by design: it generates shell commands that the user's interactive shell evals. An attacker who can poison the inputs (the failing command, its output, or the rule files) can execute arbitrary code in the user's shell.
Attack surfaces
| Surface | Risk | Mitigation |
|---|---|---|
| Untrusted rule files | A malicious ~/.config/thefuck/rules/foo.py can run any code at import time and at match() time. |
Treat rule files as trusted input. Do not pip install thefuck_contrib_* from random sources. Audit before installing. |
| Compromised cache | The shelve cache stores pickled values. If an attacker writes to ${XDG_CACHE_HOME}/thefuck, they can inject arbitrary objects on next read. |
The shelve file is 0600 mode by default; do not relax it. Delete the cache after any system compromise. |
| Compromised settings.py | An attacker who writes to ~/.config/thefuck/settings.py runs Python at every fuck invocation. |
Same as above — ~/.config/thefuck/ should be 0700. |
| Output injection via remote command | If a rule keys off a substring of the failing command's stderr, an attacker controlling that stderr (e.g., via SSH MOTD, captured Docker output) could craft a string that matches a destructive rule. | Most rules check fixed strings; the few that parse user-controlled output (apt_get, apt_invalid_operation) only generate package names, not arbitrary scripts. The repeat = True setting amplifies the risk by wrapping the suggestion in a shell call back into fuck. |
| Side-effect rules | dirty_untar deletes files in CWD; dirty_unzip likewise; ssh_known_hosts deletes known_hosts lines. |
All side-effect rules are filtered: dirty_untar only removes paths starting with os.getcwd() (so .. paths are preserved); ssh_known_hosts only edits the file path emitted by ssh's own warning. The (+side effect) marker in the prompt is a deliberate UX warning — keep require_confirmation=True for these. |
sudo rule pattern matching |
The sudo rule's pattern list includes generic strings like "permission denied" that can appear in unrelated output. A doc string or a captured log could match. |
The rule never runs sudo itself; it just suggests sudo $command. The user must confirm. If the user disables confirmation, they have accepted the implicit risk. |
rm_root rule |
Disabled by default — would suggest rm / --no-preserve-root. |
Keep it disabled. If you opt in, set priority['rm_root'] = 9999 so it never fires before more specific rules. |
git_push_force rule |
Disabled by default — suggests --force-with-lease. |
Less destructive than --force but still risky on shared branches. Keep disabled in shared CI. |
| Instant-mode shell logger | Records every command and its output to /tmp/thefuck-script-log-<uuid> (mode 0600). |
The log is mmapped at 1 MiB, with a 10 KiB high-water mark for cleanup. Anyone with read access to /tmp and the user's UID can see the log. Disable instant mode on shared boxes. |
fix_file rule |
Invokes $EDITOR on a file path extracted from output. |
The rule requires the file to actually exist (os.path.isfile(m.group('file'))), so an attacker would have to also write the named file. Risk is mostly UX: opening a file you didn't expect. |
| Argument placeholder | The THEFUCK_ARGUMENT_PLACEHOLDER = 'THEFUCK_ARGUMENT_PLACEHOLDER' constant is reserved. A user with a literal THEFUCK_ARGUMENT_PLACEHOLDER in their command would get scrambled args. |
The string is unique enough that this never happens in practice. |
Warning
Never let
~/.config/thefuck/settings.pyor any rule file be writable by another user. The Fuck imports them with full Python privileges. On multi-user systems, setchmod 0700 ~/.config/thefuck && chmod 0600 ~/.config/thefuck/settings.py.
Caution
Disabling
require_confirmation(or always passing-y) gives The Fuck the authority to run any rule's output without your review. Combine that with a contrib rule pack you didn't audit and you have a privilege-escalation primitive. Do not run withrequire_confirmation=Falsein environments that trust your interactive shell more than your install path.
Network behaviour
The Fuck makes no outbound network calls itself. Rules that do call external commands (apt-get, brew, pkgfile, gem, docker, gradle --tasks, npm run-script, etc.) inherit the user's network access. There is no telemetry, no version-check, no analytics.
Secrets
The Fuck does not handle secrets. It does, however, capture command output via the rerun, which may include credentials echoed by the failing command. That captured output is held in memory only; instant mode persists it to the log file under /tmp (1 MiB rolling buffer, mode 0600).
Warning
If a command echoes a token or password to stderr and you use instant mode, that token is on disk under
/tmp/thefuck-script-log-<uuid>until the shell exits. Disable instant mode on shared hosts or on hosts where/tmpis not user-private.
Authentication / authorisation
There is none. The Fuck operates as the invoking user and respects whatever permissions they have.
Code-signing / supply chain
Releases are uploaded to PyPI via twine upload (see release.py). PyPI checks PGP signatures if present but does not require them; the package on PyPI is the source of truth. Snap packages are confined classic (no sandboxing). Distro packages re-build from the upstream tag.
To verify integrity:
pip download thefuck --no-deps
pip hash thefuck-3.32.tar.gz
# Compare against the hash recorded by the GitHub release tag.
Reporting vulnerabilities
The project's issue tracker is the public channel. There is no SECURITY.md. For a sensitive disclosure, contact the author at [email removed].
8. Developer Internals
Repository structure
thefuck/
├── __init__.py # empty
├── argument_parser.py # Parser class, ARGUMENT_PLACEHOLDER trick
├── conf.py # Settings(dict) with attribute access
├── const.py # DEFAULT_SETTINGS, ENV_TO_ATTR, sentinels
├── corrector.py # get_rules, organize_commands, get_corrected_commands
├── exceptions.py # EmptyCommand, NoRuleMatched, ScriptNotInLog
├── logs.py # color, warn, exception, debug_time, etc.
├── types.py # Command, Rule, CorrectedCommand
├── ui.py # CommandSelector, select_command, read_actions
├── utils.py # memoize, cache, which, get_alias, Cache class
│
├── entrypoints/
│ ├── alias.py # print_alias
│ ├── fix_command.py # the orchestrator
│ ├── main.py # console_scripts entry
│ ├── not_configured.py # the splash + auto-write-on-second-run
│ └── shell_logger.py # PTY recorder for instant mode
│
├── output_readers/
│ ├── __init__.py # get_output dispatcher
│ ├── rerun.py # default — Popen + wait + kill
│ ├── read_log.py # instant mode — mmap THEFUCK_OUTPUT_LOG
│ └── shell_logger.py # external integration via Unix socket
│
├── rules/ # 169 rule files (1 per correction pattern)
│ ├── __init__.py # empty
│ ├── adb_unknown_command.py
│ ├── ag_literal.py
│ ├── apt_get.py
│ ├── ...
│ └── yum_invalid_operation.py
│
├── shells/ # shell adapters
│ ├── __init__.py # picks one via TF_SHELL or process tree
│ ├── bash.py # Bash subclass of Generic
│ ├── fish.py # Fish subclass of Generic
│ ├── generic.py # base class — shell-agnostic ops
│ ├── powershell.py # Powershell subclass of Generic
│ ├── tcsh.py # Tcsh subclass of Generic
│ └── zsh.py # Zsh subclass of Generic
│
├── specific/ # tool-specific helpers + decorators
│ ├── apt.py # apt_available
│ ├── archlinux.py # get_pkgfile, archlinux_env
│ ├── brew.py # brew_available, get_brew_path_prefix
│ ├── dnf.py # dnf_available
│ ├── git.py # @git_support decorator
│ ├── nix.py # nix_available
│ ├── npm.py # npm_available, get_scripts
│ ├── sudo.py # @sudo_support decorator
│ └── yum.py # yum_available
│
└── system/
├── __init__.py # imports unix or win32
├── unix.py # tty/termios get_key, init_output, open_command
└── win32.py # msvcrt.getwch, win_unicode_console init_output
The three core types
Command (thefuck/types.py:12)
class Command(object):
def __init__(self, script, output): ...
@property
def stdout(self): ... # deprecated alias for output
@property
def stderr(self): ... # deprecated alias for output
@property
def script_parts(self): ... # lazy shell.split_command(script)
def update(self, **kwargs): ... # return new Command with overrides
@classmethod
def from_raw_script(cls, raw_script): ...
Command is the rule's view of the failing invocation. Two fields:
script: the canonicalised command string (aftershell.from_shellalias expansion).output: the captured stdout+stderr from the rerun (or the shell-logger reader).
script_parts is the shlex'd list, lazily computed because shlex.split is not free and many rules don't need it. The @property falls back to self.script.split(' ') if shlex fails (e.g., unbalanced quotes).
Rule (thefuck/types.py:85)
class Rule(object):
def __init__(self, name, match, get_new_command,
enabled_by_default, side_effect,
priority, requires_output): ...
@classmethod
def from_path(cls, path): ...
@property
def is_enabled(self): ...
def is_match(self, command): ...
def get_corrected_commands(self, command): ...
from_path is the load-bearing classmethod:
@classmethod
def from_path(cls, path):
name = path.name[:-3] # strip .py
if name in settings.exclude_rules:
return None
rule_module = load_source(name, str(path)) # importlib.util or imp
priority = getattr(rule_module, 'priority', DEFAULT_PRIORITY)
return cls(name,
rule_module.match,
rule_module.get_new_command,
getattr(rule_module, 'enabled_by_default', True),
getattr(rule_module, 'side_effect', None),
settings.priority.get(name, priority),
getattr(rule_module, 'requires_output', True))
So a rule file can declare any of these module-level names:
| Name | Default | Meaning |
|---|---|---|
match |
required | (Command) -> bool. Called for every command after rule loading. |
get_new_command |
required | `(Command) -> str |
enabled_by_default |
True |
Whether ALL_ENABLED includes this rule. |
side_effect |
None |
(Command, str) -> None. Called BEFORE the new command is printed. |
priority |
1000 |
Lower = earlier. Per-user settings.priority overrides. |
requires_output |
True |
If False, the rule runs even when there is no captured output (e.g., git_hook_bypass, dirty_unzip). |
is_match runs the rule's match() inside a debug_time context, catches every exception, and logs Rule X failed: with traceback. Rule errors never propagate.
get_corrected_commands calls match.get_new_command(command) and yields one CorrectedCommand per result. If the result is a string, you get one. If a list, you get N (their priorities scaled by (n+1) * rule.priority so the first result wins ties).
CorrectedCommand (thefuck/types.py:201)
class CorrectedCommand(object):
def __init__(self, script, side_effect, priority): ...
def __eq__(self, other): ... # ignores priority
def __hash__(self): ... # by (script, side_effect)
def run(self, old_cmd): ...
Equality and hashing ignore priority so organize_commands can deduplicate identical scripts from different rules.
run:
- Calls
side_effect(old_cmd, self.script)if non-None. - Calls
shell.put_to_history(self.script)ifsettings.alter_history. - Logs
PYTHONIOENCODING: <value>(debug). - Writes the script to stdout. If
settings.repeat, the script is wrapped inscript || fuck --repeat --force-command "<quoted-script>".
The decorators
Authoring rules is mostly about composing decorators from thefuck.utils and thefuck.specific. The key ones:
| Decorator | Source | Behaviour |
|---|---|---|
@for_app('app1', 'app2', at_least=N) |
utils.py:1128 |
Wraps a match so it returns False unless command.script_parts[0] is one of the named apps and there are at least N+1 parts. |
@sudo_support |
specific/sudo.py:7 |
If script starts with sudo , strip it, call inner, prepend sudo to result. |
@git_support |
specific/git.py:7 |
Only call inner if is_app(command, 'git', 'hub'). Then parse trace: alias expansion: ALIAS => EXPANSION from output and rewrite command.script so rules see the resolved command. |
@memoize |
utils.py:53 |
Process-lifetime LRU. Disabled by memoize.disabled = True. |
@cache(*paths_or_binaries) |
utils.py:1211 |
Persistent shelve cache keyed by mtime of given paths. Disabled by cache.disabled = True. |
@eager |
utils.py:1086 |
Wraps a generator so it's realised to a list before returning. |
@default_settings({key: value, ...}) |
utils.py:1013 |
Adds default values to settings before calling inner. Lazy — applied only when the rule actually fires. Used by fix_file to inject fixlinecmd and fixcolcmd. |
Authoring a custom rule — full template
# ~/.config/thefuck/rules/my_company_deploy.py
"""
Match: `deploy` command failing because the user forgot to source the
shared env. Suggest sourcing it and re-running.
Author: Ops Team
"""
import os
from thefuck.utils import for_app, which
from thefuck.shells import shell
@for_app('deploy')
def match(command):
return (
'environment not loaded' in command.output.lower()
and os.path.isfile(os.path.expanduser('~/work/env.sh'))
)
def get_new_command(command):
# Returning a list yields multiple suggestions in priority order.
return [
shell.and_('source ~/work/env.sh', command.script),
shell.and_('source ~/work/env.staging.sh', command.script),
]
def side_effect(old_cmd, new_command_str):
# Optional. Runs BEFORE the new command is echoed to stdout.
# Use sparingly — side effects are user-invisible cleanups.
pass
# Optional metadata:
enabled_by_default = bool(which('deploy')) # auto-disable on machines without the CLI
priority = 500 # smaller = earlier
requires_output = True # default; set False if your rule
# works without a rerun
Drop the file in ~/.config/thefuck/rules/. Next fuck invocation picks it up.
The corrector loop
def get_corrected_commands(command):
corrected_commands = (
corrected for rule in get_rules()
if rule.is_match(command)
for corrected in rule.get_corrected_commands(command))
return organize_commands(corrected_commands)
A double generator: outer over rules, inner over each rule's emitted suggestions. organize_commands peeks the first command (so NoRuleMatched can bubble), then realises the rest, deduplicates by (script, side_effect), and emits in priority order. The peek-first design means the prompt can show suggestion #1 immediately while remaining rules are still being evaluated.
The shell adapter pattern
thefuck/shells/generic.py defines the abstract base. Subclasses override what they need:
| Method | Generic default | Per-shell override |
|---|---|---|
from_shell(script) |
calls _expand_aliases |
Fish overrides to fall back to fish -ic for non-binary aliases |
to_shell(script) |
identity | (no overrides) |
app_alias(name) |
a one-line alias=eval... |
Bash, Zsh, Fish, Tcsh, PowerShell each emit a multi-line function with shell-specific syntax |
instant_mode_alias(name) |
warns + falls back | Bash and Zsh emit the script(1) recorder wrapper |
get_aliases() |
{} |
Bash/Zsh parse TF_SHELL_ALIASES; Fish runs fish -ic alias/functions; Tcsh runs tcsh -ic alias |
_get_history_file_name() |
'' |
Bash/Tcsh: HISTFILE env var; Zsh: ~/.zsh_history; Fish: ~/.config/fish/fish_history |
_get_history_line(script) |
'' |
Each shell formats as needed (Tcsh prepends #+<ts>, Zsh prepends : ts:0;, Fish writes a YAML-style entry) |
and_(*cmds) |
' && '.join |
Fish: '; and '; PowerShell: -and-joined parens |
or_(*cmds) |
' || '.join |
Fish: '; or ' |
how_to_configure() |
None |
Each shell points at the appropriate rc file |
_get_version() |
'' |
Each runs the shell with a version-printing command |
put_to_history(script) |
no-op | Fish appends to fish_history; others rely on the alias function's history -s/print -s |
quote(s) |
shlex.quote |
(no overrides) |
split_command(script) |
shlex with ?? placeholder for escaped spaces |
(no overrides) |
get_builtin_commands() |
hard-coded list of POSIX builtins | (no overrides) |
The output reader pattern
thefuck/output_readers/__init__.py:get_output picks one of three implementations:
def get_output(script, expanded):
if shell_logger.is_available():
return shell_logger.get_output(script)
if settings.instant_mode:
return read_log.get_output(script)
else:
return rerun.get_output(script, expanded)
The default (rerun) is a 30-line wrapper around Popen plus psutil.Process.wait with timeout. Two subtleties:
- Process tree kill: on timeout,
proc.children(recursive=True)is enumerated and each is killed before the parent. This avoids zombie GUI tools or backgrounded daemons. - Encoding: stdout is decoded as
utf-8witherrors='replace'. Bytes that don't decode become U+FFFD; rules should never assume clean ASCII.
read_log (instant mode) groups output by the USER_COMMAND_MARK (10 zero-width spaces) embedded in PS1 and reconstructs the visual display via pyte.Screen to handle wrapped lines.
shell_logger is the external-integration path: if SHELL_LOGGER_SOCKET env var points at a Unix socket, The Fuck connects, sends a JSON {"type": "list", "count": 5} request, and reads back a list of {"command": str, "output": str} for the last 5 commands. Used by recording wrappers like external session-recording integrations.
fastentrypoints.py
Vendored at the repo root. It monkey-patches setuptools' easy_install.ScriptWriter.get_args to emit a faster console-script template:
TEMPLATE = r'''\
# -*- coding: utf-8 -*-
# EASY-INSTALL-ENTRY-SCRIPT: '{3}','{4}','{5}'
__requires__ = '{3}'
import re
import sys
from {0} import {1}
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw?|\.exe)?$', '', sys.argv[0])
sys.exit({2}())
Notice no pkg_resources.require — that's the optimisation. On installs with many distributions on sys.path, pkg_resources.require can take 100+ ms.
Test strategy
tests/ mirrors the source layout. There are seven categories:
| Test dir | Focus |
|---|---|
tests/ (root) |
argument_parser, conf, corrector, logs, readme, types, ui, utils |
tests/entrypoints/ |
alias, fix_command, not_configured |
tests/functional/ |
Docker-spawned shell sessions via pytest-docker-pexpect. Skipped by default; enabled via --enable-functional. Covers Bash, Fish, Tcsh, Zsh end-to-end. |
tests/output_readers/ |
rerun |
tests/rules/ |
One file per rule. Each tests match and get_new_command with mocked output. |
tests/shells/ |
One file per shell adapter. Validates alias output, history file detection, app_alias_variables_correctly_set. |
tests/specific/ |
git_support, sudo_support, npm helpers |
The tests/conftest.py fixtures auto-disable memoization and the persistent cache, reset settings to defaults, and clear os.environ before every test (preserving only PATH). This isolation means tests that look at environment behaviour must explicitly set their env.
tests/Dockerfile is the basis for functional tests:
ARG PYTHON_VERSION
FROM python:${PYTHON_VERSION}
RUN apt-get update -y
RUN apt-get install -yy --no-install-recommends --no-install-suggests fish tcsh zsh
RUN pip install --upgrade pip
COPY . /src
RUN pip install /src
Code patterns and conventions
- Decorators over inheritance. Rules compose from
@for_app,@sudo_support,@git_support. There is no Rule base class to subclass. - Generator-heavy. The corrector chains generators end-to-end so the prompt can show suggestion #1 before suggestion #N is computed.
- Early returns with short circuits. Most
match()functions are a singlereturnstatement combining 2–4 checks. script_partsoverscript.split(). Rules consistently use the lazy property so escaped spaces and quotes are handled.re.findall+[0]. A common pattern in rules isre.findall(pattern, output)[0]— guarded by thematch()having already verified the pattern is present. If bothmatchandget_new_commandextract the same regex, the typical pattern is to define a module-level compiled regex and use it in both.@for_appis mandatory for app-specific rules. It both filters out non-matching apps and provides theat_leastarg-count guard. Without it, you'd see lots ofIndexError: list index out of rangetraces infuck -d.- Six compatibility —
from six.moves.urllib import parse,import sixchecks, etc. The project is straddling Python 2/3 even though Python 2 is deprecated. - No type hints. Type information is in docstrings as
:type/:rtypefor backward compat with Python 2.
Adding a new shell
- Subclass
Genericinthefuck/shells/<name>.py. - Override
app_alias,friendly_name,_get_history_file_name,_get_history_line,how_to_configure,_get_version. Overrideand_/or_if your shell uses non-&&/||separators. - Register in
thefuck/shells/__init__.py:shells = {...}. - Add tests under
tests/shells/test_<name>.pymirroring the test_bash structure. - If your shell supports instant mode, override
instant_mode_alias.
Adding a new tool helper
Drop thefuck/specific/<tool>.py containing:
- An
<tool>_available = bool(which('<tool>'))flag. - Any cached helpers (
@memoize/@cache). - Any decorator like
@<tool>_supportif rules need to consistently strip/wrap the tool name.
Then rules can do from thefuck.specific.<tool> import <tool>_available and set enabled_by_default = <tool>_available.
Potential improvements (visible in the source)
These are areas the source itself flags or that would benefit from a cleanup pass:
| Area | Observation | Remediation |
|---|---|---|
| Python 2 compatibility | six shims everywhere; setup.py lists python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*'. |
Drop Py2 once 3.5+ is the floor. The maintainer warned about this on Py2 in entrypoints/alias.py. |
imp.load_source fallback |
Used when importlib.util is unavailable. Removed in Python 3.12 (issue #1527). |
Already handled with try/except; just drop the fallback for 3.12+. |
distutils |
system/unix.py imports from distutils.spawn import find_executable — removed in 3.12 (issue #1444). |
Replace with shutil.which. |
pkg_resources |
setup.py and get_installation_version use pkg_resources — deprecated in newer setuptools (issue #1552). |
Use importlib.metadata exclusively. |
pyte<0.8.1 pin for Py2.7 |
extras_require pins pyte<0.8.1 and decorator<5 for Py2.7. |
Drop together with Py2 support. |
CommandNotFound API |
apt_get rule has two code paths (Ubuntu 18.04+ and older). |
Consolidate when older versions are EOL. |
| Pattern lists | The sudo rule's pattern list is hard-coded; new locale-specific permission errors require source changes. |
Could move to settings.py or a JSON file. |
| Test coverage for instant mode | Instant mode is hard to test outside a real PTY; coverage is light. | Functional tests exist for some shells but not for instant-mode interactions. |
| Cache eviction | Cache never garbage-collects; entries are pinned by etag only. |
A periodic shelve.sync() and an LRU bound would be helpful for long-lived shells. |
| Alias regen cost | thefuck --alias is invoked once per shell start; output is several KiB of shell code. Caching to disk would shave 30–80 ms. |
Consider an on-disk cache keyed by The Fuck version + shell version. |
| README detection | The presence/absence of docs/README is currently invisible to The Fuck itself; CONTRIBUTING.md exists but is not surfaced to users via the --help. |
Cosmetic. |
Contributing
| Step | Detail |
|---|---|
| Code style | flake8-clean. The repo includes .editorconfig and a setup.cfg with project metadata. |
| Tests | All new rules must include a tests/rules/test_<name>.py. Functional tests exist for shell adapters. |
| New rule checklist | (1) match returns boolean, (2) get_new_command returns string or list, (3) priority set if non-default, (4) requires_output = False if rule keys only off command.script, (5) test file with at least one matching and one non-matching case. |
| PR target | master branch. |
| Author | Vladimir Iakovlev. Project status discussed in issues #1466 and #1566. |
9. Integration Points
Inputs
| Input | Source | Notes |
|---|---|---|
| The failing command | command positional arg, or TF_HISTORY env, or shell fc -ln -10 |
Set by the alias before invocation |
| Captured output | rerun subprocess, instant-mode log, or socket | One of three readers |
| Shell aliases | TF_SHELL_ALIASES (Bash/Zsh) or fish -ic alias (Fish) or tcsh -ic alias (Tcsh) |
Required for alias expansion in from_shell |
| Shell history | shell-specific history file | Read by history and path_from_history rules |
| Settings | settings.py + env vars + flags |
Layered |
| User custom rules | ~/.config/thefuck/rules/*.py |
Auto-loaded |
Outputs
| Output | Sink | Format |
|---|---|---|
| Corrected command | stdout | A single string (the alias evals it) |
| Confirmation prompt | stderr | Color-formatted with the USER_COMMAND_MARK prefix |
| Debug logs | stderr | [DEBUG], [WARN] prefixes |
| Shell alias | stdout (when --alias used) |
Multi-line shell function source |
| Shell-logger output | a tempfile (instant mode) or a Unix socket (external integration) | Raw terminal bytes |
Extension points
| Extension | API | Discovery |
|---|---|---|
| User rules | match / get_new_command Python module |
~/.config/thefuck/rules/*.py |
| Third-party rule packs | Same | Any pip package whose name matches thefuck_contrib_* and contains a rules/ directory |
| Custom shell | Subclass Generic and register in shells/__init__.py:shells |
Source modification needed |
| External output reader | Implement is_available() and get_output(script) |
Set SHELL_LOGGER_SOCKET env var pointing at a Unix socket |
EDITOR-based fix |
fix_file rule reads $EDITOR |
Must be set by the user |
| Per-tool decorators | @for_app('toolname') |
Add new ones in thefuck/utils.py or in your rule file |
External tools touched
The Fuck shells out to a long list of external tools — only the rules that need them. A non-exhaustive list:
| Tool | Used by | Purpose |
|---|---|---|
bash, zsh, fish, tcsh, powershell, pwsh |
shell adapters | Version detection, alias listing |
lsof |
port_already_in_use |
PID for a port |
pkgfile |
pacman, pacman_not_found |
Arch package lookup |
apt-get, apt-cache, apt |
apt rules | Help text parsing |
dnf, yum |
dnf/yum rules | Help text parsing |
brew |
brew rules | Path prefix, command listing |
docker |
docker_not_command |
Help text parsing |
gem, npm, yarn, gradle, gradlew, mvn, lein, grunt, gulp, react-native, cargo, go, pyenv/rbenv/nodenv/goenv |
corresponding rules | Help text / scripts listing |
script |
instant mode | PTY recorder |
xdg-open (Linux), open (macOS), cmd /c start (Windows) |
open rule, yarn_help rule |
Open URLs in a browser |
nix-env |
nixos_cmd_not_found |
Suggested install command |
What The Fuck does NOT integrate with
To enumerate the negative knowledge set:
- No HTTP server. No
/health, no metrics endpoint. - No database. State is on disk: settings.py, shelve cache.
- No daemon mode. Every invocation is a fresh Python process.
- No GraphQL/REST/gRPC. No public API.
- No webhooks. No outbound network calls.
- No queueing or async. Single-threaded, synchronous.
- No telemetry / analytics. No version-check ping.
- No GUI. Terminal-only via
coloramaANSI codes. - No language server / IDE plugin. CLI-first.
- No WebSocket / SSE. No real-time channels.
- No Kubernetes integration. No CRD, no operator. Run as a per-user CLI.
- No SSO / OAuth. Inherits user's shell session.
- No JSON output mode.
thefuckalways emits a shell-evalable string. - No man page bundled in source. Distro packages may add one. Help is via
-honly.
Operational checklist for an enterprise rollout
- [x] Audit
~/.config/thefuck/rules/and anythefuck_contrib_*packages for malicious code paths. - [x] Set permissions:
chmod 0700 ~/.config/thefuck && chmod 0600 ~/.config/thefuck/settings.py. - [x] Disable
rm_rootand keepgit_push_forceoff (both are off by default). - [x] Set
require_confirmation = True(default) for shared/production accounts. - [x] Distribute a curated
rules = [...]whitelist via your dotfiles repo. - [x] Pre-populate
slow_commandsto cover your build-tool fleet. - [x] Decide whether instant mode is allowed (it persists output to
/tmp). - [x] Pin a Python version (3.10 or 3.11) to avoid Py3.12+
imp/distutilsissues. - [x] Document the alias line for every supported shell in your onboarding docs.
- [x] If you ship a
thefuck_contrib_<team>rule pack, version it independently and gate releases through code review.