Self-healing autopoietic shell: f(), tests, docs, interactive demo, fx fix-source layer

This commit is contained in:
2026-08-17 22:30:18 -04:00
commit 8b30120fba
14 changed files with 767 additions and 0 deletions
+57
View File
@@ -0,0 +1,57 @@
# fx — automated, selectable fix sources for f()
`fx` automates the `[LEARN] ... Enter a fix:` prompt in `f()` by gathering
candidate fixes from a directory of small, pluggable scripts and letting
you pick one (or auto-picking) before it's piped into `f`'s stdin.
**Zero changes to `../../src/f.sh`.** `f()`'s `read -r n` already doesn't
care whether that line comes from a human or a program — `fx` just
decides what to type there.
## Usage
```sh
. ../../src/f.sh
. fx.sh
fx some-command --that --might --fail
```
Set `FX_AUTO=1` to skip the picker and take the top-ranked candidate
automatically. Without it, `fx` uses `fzf` if installed, or a plain
numbered prompt otherwise.
## Default sources (`sources.d/`, run in filename order)
| File | What it offers |
|---|---|
| `10-known.sh` | Fixes already taught for a command that *starts* the same way, since `f()`'s own lookup only matches the exact string. |
| `20-history.sh` | The closest-looking command you've actually run before, read from `$HISTFILE`. |
| `30-selfdiag.sh` | Re-runs the command once, and if the tool's own error output suggests a corrected invocation (git's `--set-upstream` hint, etc.), emits a **function that re-derives the suggestion live on every future call** — not a frozen snapshot. This distinction matters: an earlier version of this source cached the literal suggested line and it silently broke on the second branch it saw (`git ls-remote` showed the second branch never got pushed). Re-diagnosing on every call is what makes one taught fix generalize correctly. |
| `40-pathfuzzy.sh` | If the first word isn't a real command, offers a **function** (not an alias — aliases don't expand in non-interactive shells without `shopt -s expand_aliases`) wrapping the closest-spelled real command, using transposition-aware edit distance so `gti` scores closer to `git` than to unrelated same-length commands like `ftp`. |
| `50-thefuck.sh` | Bridges to `thefuck --yes`, if installed, as one more opinion. Note `--yes` executes its own suggestion, so this candidate is often already-applied by the time you see it. |
| `90-team-shared.sh` | Off by default (no-ops unless `FX_TEAM_URL` is set) — see below, this one *is* the extension example. |
## Adding a custom source
Drop a new executable file into `sources.d/`. Nothing else to register.
Contract:
- invoked as `sourcefile "$CMD"` where `$CMD` is the whole failing command line
- may read `$FX_OUTPUT` — captured stdout+stderr of one real attempt (empty if `FX_NO_PROBE` is set)
- prints zero or more candidate fix lines to stdout, one per line
- non-zero exit or no output = "no opinion," silently skipped
- filename prefix (`NN-name`) sets consideration/display order — lower runs first
`90-team-shared.sh` is a real, working example of this contract: it pulls
a shared `p`-format corrections file from `$FX_TEAM_URL` and offers
exact-key matches. It's a template, not a dependency — treat a URL you
point it at the same way you'd treat an rc file you `source`: a match
becomes `eval`'d code the moment you select it.
## Known cost
`fx` runs the command once itself before consulting sources (so
`30-selfdiag.sh` has real output to read). That's one extra execution
beyond what plain `f()` does. Set `FX_NO_PROBE=1` to skip it for
commands with side effects you don't want repeated — the output-aware
source just has no opinion in that case, and the others still work.
+83
View File
@@ -0,0 +1,83 @@
#!/bin/sh
# fx: automates f()'s "enter a fix" prompt by gathering candidate fixes
# from a directory of pluggable sources and letting you pick one (or
# auto-picking the top one) before piping it into f's stdin.
#
# Zero changes to f.sh: this only decides what gets typed at the
# "[LEARN] ... Enter a fix" prompt, using the stdin f() already reads.
#
# Usage:
# . src/f.sh
# . contrib/fx/fx.sh
# fx some-command --that --might --fail
#
# Env vars:
# FX_SOURCES_DIR override the sources directory (default: sources.d
# next to this script)
# FX_AUTO if set, auto-pick the top candidate instead of
# showing a picker
# FX_NO_PROBE if set, skip fx's own diagnostic run of the command
# (cheaper, but the 30-selfdiag.sh source gets no
# output to read and will have no opinion)
#
# Source contract (see sources.d/*.sh):
# - any executable file in $FX_SOURCES_DIR
# - invoked as: sourcefile "$CMD"
# - may read $FX_OUTPUT (captured stdout+stderr of one real attempt,
# empty if FX_NO_PROBE was set)
# - prints zero or more candidate fix lines to stdout, one per line
# - non-zero exit or no output = "no opinion", silently skipped
# - filename prefix (NN-name) sets display/consideration order
#
# Adding a source: drop a new executable file in sources.d/ that follows
# the contract above. Nothing else to register or edit.
# $0 is the enclosing shell's name when this file is *sourced*, not this
# file's own path -- ${BASH_SOURCE[0]} is reliable under bash; for other
# POSIX shells, set FX_SOURCES_DIR explicitly before sourcing this file.
_fx_self="${BASH_SOURCE:-$0}"
FX_SOURCES_DIR="${FX_SOURCES_DIR:-$(CDPATH= cd -- "$(dirname -- "$_fx_self")" 2>/dev/null && pwd)/sources.d}"
unset _fx_self
fx() {
cmd="$*"
FX_OUTPUT=""
if [ -z "$FX_NO_PROBE" ]; then
FX_OUTPUT=$("$@" 2>&1)
if [ $? -eq 0 ]; then
printf '%s\n' "$FX_OUTPUT"
return 0
fi
fi
export FX_OUTPUT
candidates=$(
for src in "$FX_SOURCES_DIR"/*; do
[ -x "$src" ] || continue
label=$(basename "$src")
"$src" "$cmd" 2>/dev/null | while IFS= read -r line; do
[ -n "$line" ] && printf '[%s] %s\n' "$label" "$line"
done
done | awk -F'] ' '!seen[$2]++'
)
chosen=""
if [ -n "$candidates" ]; then
if [ -n "$FX_AUTO" ]; then
chosen=$(printf '%s\n' "$candidates" | head -1 | sed 's/^\[[^]]*\] //')
elif command -v fzf >/dev/null 2>&1; then
chosen=$(printf '%s\n' "$candidates" | fzf --prompt="fix for: $cmd > " --height=40% | sed 's/^\[[^]]*\] //')
else
i=0
printf '%s\n' "$candidates" | while IFS= read -r line; do
i=$((i+1)); printf '%d) %s\n' "$i" "$line"
done
printf 'pick a number (blank to type your own): '
read -r n
[ -n "$n" ] && chosen=$(printf '%s\n' "$candidates" | sed -n "${n}p" | sed 's/^\[[^]]*\] //')
fi
fi
printf '%s\n' "$chosen" | f "$@"
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# Fuzzy-known source: surfaces fixes already taught for a command that
# *starts the same way* as this one, since f()'s own p lookup only ever
# matches the exact, literal string.
[ -f p ] || exit 0
cmd="$1"
first_word=$(printf '%s' "$cmd" | awk '{print $1}')
[ -n "$first_word" ] || exit 0
grep -F "${first_word} " p 2>/dev/null | cut -d= -f2- | awk '!seen[$0]++'
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# History source: suggests the closest-looking command you've actually
# typed and moved on from before (thefuck's history.py idea). Reads
# HISTFILE directly since a separate process doesn't inherit the parent
# shell's in-memory history.
cmd="$1"
first_word=$(printf '%s' "$cmd" | awk '{print $1}')
[ -n "$first_word" ] || exit 0
hf="${HISTFILE:-$HOME/.bash_history}"
[ -r "$hf" ] || exit 0
tail -n 300 "$hf" 2>/dev/null | grep -F -- "$first_word " | grep -vF -- "$cmd" | awk '!seen[$0]++' | tail -5
+23
View File
@@ -0,0 +1,23 @@
#!/bin/sh
# Self-diagnosis source: many CLIs print their own corrected invocation
# in their error output (git's --set-upstream hint, apt's "did you
# mean", etc.).
#
# IMPORTANT: this must NOT cache the literal suggested line -- that
# reintroduces the exact-key staleness problem (e.g. "git push" learned
# once on branch A silently misapplies its captured --set-upstream on
# branch B, and B never actually gets pushed). Instead it emits a
# function that shadows the whole program and re-derives the suggestion
# from *live* output on every call, the same trick proven to generalize
# correctly across branches/arguments in practice. One taught fix this
# way covers every future self-diagnosing failure from that program, not
# just the one instance that happened to trigger the teaching prompt.
cmd="$1"
prog=$(printf '%s' "$cmd" | awk '{print $1}')
[ -n "$prog" ] || exit 0
[ -n "$FX_OUTPUT" ] || exit 0
printf '%s\n' "$FX_OUTPUT" | grep -qE "^[[:space:]]*${prog}[[:space:]]" || exit 0
cat <<FIX
${prog}(){ out=\$(command ${prog} "\$@" 2>&1); ec=\$?; if [ \$ec -ne 0 ]; then sug=\$(printf '%s\\n' "\$out" | grep -E "^[[:space:]]*${prog}[[:space:]]" | sed 's/^[[:space:]]*//' | tail -1); if [ -n "\$sug" ]; then eval "command \$sug"; return \$?; fi; fi; printf '%s\\n' "\$out"; return \$ec; }
FIX
+49
View File
@@ -0,0 +1,49 @@
#!/bin/bash
# PATH-fuzzy source: if the first word isn't a known command, alias, or
# function, offer a function wrapping the closest-spelled real command
# as a candidate -- as a fix you approve through the normal f() flow,
# not a silently auto-installed hook. A function, not an alias: aliases
# only expand in interactive shells (need `shopt -s expand_aliases`
# otherwise), so an alias-based fix silently does nothing when f/fx run
# inside a script.
#
# Uses Damerau-Levenshtein-lite (edit distance + adjacent transposition)
# so the canonical "gti" -> "git" typo scores 1, not 2 -- plain Hamming
# or substitution-only distance ties it with unrelated 3-letter commands
# like "ftp" and can pick the wrong one.
cmd="$1"
first_word=$(printf '%s' "$cmd" | awk '{print $1}')
[ -n "$first_word" ] || exit 0
command -v "$first_word" >/dev/null 2>&1 && exit 0
type "$first_word" >/dev/null 2>&1 && exit 0
compgen -c 2>/dev/null | sort -u | awk -v target="$first_word" '
function min3(a, b, c) { return (a < b ? (a < c ? a : c) : (b < c ? b : c)) }
function distance(a, b, n, m, i, j, ca, cb, cost, tmp) {
n = length(a); m = length(b)
if (n == 0) return m
if (m == 0) return n
for (j = 0; j <= m; j++) d[0, j] = j
for (i = 1; i <= n; i++) {
d[i, 0] = i
ca = substr(a, i, 1)
for (j = 1; j <= m; j++) {
cb = substr(b, j, 1)
cost = (ca == cb) ? 0 : 1
tmp = min3(d[i-1, j] + 1, d[i, j-1] + 1, d[i-1, j-1] + cost)
if (i > 1 && j > 1 && ca == substr(b, j-1, 1) && substr(a, i-1, 1) == cb) {
tmp = (tmp < d[i-2, j-2] + 1) ? tmp : d[i-2, j-2] + 1
}
d[i, j] = tmp
}
}
return d[n, m]
}
{
lendiff = length($0) - length(target)
if (lendiff > 2 || lendiff < -2) next
dd = distance(target, $0)
if (dd <= 2 && dd > 0 && (best == "" || dd < bestd)) { best = $0; bestd = dd }
}
END { if (best != "") print target "(){ command " best " \"$@\"; }" }
'
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# thefuck bridge: only fires if thefuck is installed. Note that thefuck
# --yes decides *and executes* its own suggestion, so by the time this
# candidate is shown it may have already run once via thefuck -- treat
# it as informational, not a dry-run.
command -v thefuck >/dev/null 2>&1 || exit 0
cmd="$1"
[ -n "$cmd" ] || exit 0
thefuck --yes "$cmd" 2>/dev/null | tr -d '\342\200\213' | tail -1
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# EXAMPLE custom source -- a template, not a real dependency. This is
# the entire extension mechanism demonstrated: any executable dropped
# into sources.d/ following the contract in fx.sh is picked up
# automatically, no registration step anywhere else.
#
# Pulls a team-shared p-format corrections file and offers exact-key
# matches. Off by default (no-ops unless FX_TEAM_URL is set) because a
# match here becomes eval'd code the moment you select it -- point this
# at a URL you trust the same way you'd trust an rc file, never a
# random link.
[ -n "$FX_TEAM_URL" ] || exit 0
cmd="$1"
[ -n "$cmd" ] || exit 0
curl -fsSL "$FX_TEAM_URL" 2>/dev/null | grep -F "${cmd}=" | cut -d= -f2-