9.8 KiB
Conclusion: Cross-Shell Syntax for the Command-Not-Found and Plexer Hooks (bash/zsh/fish)
Metadata
| Field | Value |
|---|---|
| Institute | Molodetz Institute — Research & Retrieval Division |
| Subject | shell-multiplexer-auto-attach |
| Document type | Conclusion |
| Tier | N/A |
| Date decided | 2026-10-10 |
| Decided by | retoor, advised by tai (agent, Claude Sonnet 5) |
| Status | Active |
| Confidence | High |
Question / problem statement
The original shell-multiplexer-auto-attach research (2026-10-09) recommended and specified the bash-only command-not-found hook and plexer auto-wrap hook, but explicitly built on bash's own .bashrc/$-/command_not_found_handle primitives throughout. retoor asked for the same user-visible behavior — a bare tai command and any unknown command reaching the agent, and a fresh interactive shell transparently dropping into a managed plexer session — to work identically under zsh and fish, not just bash, and asked specifically how to detect each shell and how to avoid any risk of corrupting an existing shell config file while doing it (duplicate blocks, clobbered content, etc.). Deep research into the exact zsh/fish syntax was explicitly requested before writing any shell-specific code.
Conclusion
zsh needs no new syntax at all for the plexer hook: zsh is POSIX-enough that the exact same [/case "$-" in *i*)/$TMUX/exec block already used for bash (PLEXER_BASHRC_BLOCK) works byte-for-byte unchanged — only the destination file differs (~/.zshrc instead of ~/.bashrc). The only thing that changes for zsh's command-not-found hook is the function name: command_not_found_handler (with an "-er" suffix), not bash's command_not_found_handle; the body is otherwise identical.
fish needs genuinely new syntax throughout, because it shares no syntax primitives with POSIX shells: status is-interactive replaces case "$-" in *i*); test/and-chained conditions replace [ ] && [ ]; the isatty stdin/isatty stdout builtin replaces [ -t 0 ]/[ -t 1 ] (fish's own test builtin has no -t file-descriptor primary, confirmed by an explicit, otherwise-inconclusive search); and the command-not-found hook is a function fish_command_not_found definition receiving $argv, not a POSIX-named handler function. Critically, fish's config.fish is sourced on every fish startup — interactive or not, login or not, unlike bash's .bashrc/zsh's .zshrc — so the status is-interactive guard inside the fish plexer block is doing real, load-bearing work (not just defense-in-depth the way it is for bash/zsh), confirmed end-to-end by FishNonInteractiveSkipTest actually running a plain non-interactive fish -c against the installed hook and checking no plexer socket gets created.
The config-corruption risk the user was worried about is handled the same way the original bash-only hooks already handled it, extended rather than reinvented: every block (six total now: notfound × {bash, zsh, fish} and plexer × {bash, zsh, fish}) is wrapped in its own unique begin/end marker pair, upserted via _rc_block_upsert/removed via _rc_block_remove (shared helpers added in this pass, refactored out of the near-duplicate bash-only functions that already existed), which always back up the target file once (<file>.bak-tai) before any write and always strip any stale marked block before re-inserting, making install/reinstall/uninstall idempotent and non-duplicating by construction. A dedicated test (DualHookSameFileIsolationTest in tests/bashrc.py) proves the notfound and plexer blocks coexist in the same rc file (all three shells put both hooks in one file) without clobbering each other or surrounding user content, for all three shells, deterministically and without needing a real shell.
Supporting sources
../fishshell-docs-4-8-fish-command-not-found.md(Tier 1) — thefish_command_not_foundfunction mechanism and its$argvargument shape.../zsh-org-mla-2015-msg00931-command-not-found-handler.md,../zsh-org-mla-2009-msg00989-command-not-found-handler.md(Tier 1) — confirmed the exact zsh function name (command_not_found_handler) and that no autoload/registration step is needed beyond the definition.../oracle-docs-e88353-zshbuiltins-autoload-x.md(Tier 1) — ruledautoload -Xout as something tai's own (static, non-dynamic) zsh handler needed to use.../fishshell-docs-3-7-isatty.md(Tier 1) — confirmed fish'sisattybuiltin (and the absence oftest -t) as the tty-check primitive for the fish plexer block.../fishshell-docs-4-9-status.md(Tier 1) — confirmedstatus is-interactive/status is-loginas fish's$--equivalent.../fishshell-blog-release-3-4-0.md(Tier 1) — an honest negative result on pinningisatty's exact introduction version; didn't block the decision (3.2+ is more than sufficient) but recorded rather than silently dropped.../fishshell-docs-3-4-index-initialization-files.md(Tier 1) — the single most consequential finding:config.fishis always sourced, making thestatus is-interactiveguard load-bearing rather than redundant.../github-pyenv-wiki-unix-shell-initialization.md(Tier 1, secondary/unconfirmed) — corroborating detail on fish's full startup-file ordering; not independently relied on beyond what the official docs already established.../man-archlinux-org-fish-1-en-interactive-flag.md(Tier 1) — confirmedfish -ias a real, stable way to force an interactive fish process, used directly in the new integration tests.
Alternatives considered and rejected
- Renaming the existing
bash-hook/plexer-hookinstall targets to a single shell-agnostic name (e.g.shell-hook) that auto-detects and writes into whichever rc files are present. Rejected in favor of adding explicitzsh-hook/fish-hook/zsh-plexer-hook/fish-plexer-hooktargets alongside the untouched originals: explicit, narrowly-scoped, independently testable per shell, and zero risk to the existing bash-hook/plexer-hook behavior and tests already relying on those exact names — consistent with "loud and explicit over silently magic." - Silently installing into every shell's rc file regardless of whether that shell is present on the machine. Rejected: writing a dangling hook into
~/.zshrcon a machine with no zsh installed is exactly the kind of silent, narrow-blast-radius violation this project's own conventions reject. Each new shell-specific install op checksshutil.which(<shell>)first and skips loudly ("zsh-hook: zsh not found, skipped") on install/upgrade; uninstall/reinstall still act on the file regardless, so a hook installed before a shell was later removed still gets cleaned up. - A from-scratch generic per-shell templating system (e.g. a single parameterized block description compiled to each shell's syntax). Rejected: fish's syntax is different enough (no
[/case/$-, no-ttest primary) that a shared template would need shell-specific escape hatches for nearly every line anyway, buying no real abstraction over just writing fish's own three blocks directly — "boring technology... fewer moving parts" favored the direct approach actually taken.
Risks / open questions
The exact fish version that introduced isatty was not pinned down (documented since at least 3.2; see fishshell-blog-release-3-4-0.md's negative result) — not a practical risk for any fish a user would realistically have installed today, but noted rather than silently assumed. The FISH_PLEXER_BLOCK's reliance on status is-interactive as config.fish's only interactivity gate (unlike bash/zsh, where the rc file itself is normally interactive-scoped too) means a future fish release that changed status is-interactive's semantics would be a higher-blast-radius regression for fish than for bash/zsh; this is exactly why FishNonInteractiveSkipTest exists as a real, running-fish-binary test rather than a unit test of the file content alone.
Action taken
Implemented in full in the same session as this research, not deferred: tai.py gained shell_rc_path()/shell_present(), the ZSH_NOTFOUND_BLOCK/FISH_NOTFOUND_BLOCK/FISH_PLEXER_BLOCK constants, the shared _rc_block_present/_rc_block_remove/_rc_block_upsert helpers (the existing bash-only functions were refactored to delegate to these rather than duplicating the logic a third and fourth time), the per-shell zsh_hook_present/upsert_zsh_hook_block/remove_zsh_hook_block family (and fish/plexer equivalents), the shared op_shell_hook() plus op_zsh_hook/op_fish_hook/op_zsh_plexer_hook/op_fish_plexer_hook, four new INSTALL_TARGETS entries, updated status_lines()/install_report()/the install tool's description, and install_self() now additionally detects $SHELL and installs the matching shell's hook. Tests: new unit tests in test_tai.py (InstallOpsTests, InstallTests) covering install/uninstall/idempotency/skip-when-missing/XDG_CONFIG_HOME/directory-creation for both new shells, all passing alongside the full existing 226-test suite (237 after additions); new real-shell integration tests in tests/bashrc.py (ZshInstallAndAutoWrapTest, ZshAntiRecursionTest, ZshTmuxActiveSkipTest, ZshNonInteractiveSkipTest, and the fish equivalents, plus DualHookSameFileIsolationTest), gated the same way the existing bash tests are (TAI_CONTAINER_TEST=1-only); tests/docker/Dockerfile now installs zsh and fish alongside bash so those real-shell tests actually run in the disposable container rather than only existing as aspirational code. README.md's Installation section was rewritten to document all eleven install targets accurately (it previously undercounted them and didn't mention plexer-hook at all, a pre-existing documentation gap fixed in passing since this edit touched that exact paragraph).
Changelog
| Date | Change |
|---|---|
| 2026-10-10 | Initial conclusion recorded. |