Skip to content

feat(hook): let programs replace the typed line before it runs (CODIGEST_REWRITERS) - #54

Merged
NaNIsaac merged 3 commits into
mainfrom
feat/rewriters
Oct 28, 2026
Merged

NaNIsaac merged 3 commits into
mainfrom
feat/rewriters

Conversation

@NaNIsaac

@NaNIsaac NaNIsaac commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

A program that turns a typed line into another one, such as hf from hanfix (it re-runs the previous command, fixed), runs that command from inside its own function today. Everything that watches the Enter key then sees hf, not the command that ran: shell history, PSReadLine and engine history, preexec hooks (atuin, starship), $?, and in bash/zsh a declare in the fixed line stays local to the function. This adds a contract so the replacement happens before the shell accepts the line, and the new line then goes on as if typed.

Contract (CODIGEST_REWRITERS)

  • PowerShell: $global:CODIGEST_REWRITERS += 'name'; the function gets the line and returns $null (not its line), the new line, or @{ Line = <new>; Confirm = $true } to ask first.
  • bash/zsh: CODIGEST_REWRITERS="${CODIGEST_REWRITERS-} name"; the function sets REPLY and returns 0 (run) or 2 (ask), or 1 (not its line).
  • The first function that claims the line wins. Asking is the hook's: the new line with [Enter / Ctrl+C]; Enter runs it, any other key or Ctrl+C drops it (nothing runs, no history entry, $? unchanged).
  • Fail-open: a function that throws or writes an error, returns another status or value, gives a line that does not parse, or answers after more than 200 ms leaves the typed line to run unchanged, with no message. That is not a timeout: the shell cannot interrupt a function, so a slow one still delays that line by its own time, and one that hangs blocks Enter.
  • The function does not touch history; the hook records the line that runs. A leading space on the typed line is kept, so the opt-out still applies. Not applied in a PowerShell remote session, nested prompt or the debugger.
  • CODIGEST_RUNNERS and __cdg_run stay as they are: they cover hf inside a longer line, scripts, and programs that do not rewrite. With an empty or unset CODIGEST_REWRITERS nothing changes.

README (English and Korean), "Programs that run other commands".

How

  • PowerShell: after the read-line returns, the hook applies the rewriters, then decides on capture for the result; the engine history gets the new line. PSReadLine adds a line to its history before it returns it, so while the hook's read-line reads (and only when a rewriter is listed) its AddToHistoryHandler holds the line back, and the hook calls AddToHistory for the line that runs. The handler that was set before still decides for that line: PSReadLine's own (a Func<string, object> that leaves out lines that look like secrets) or a profile's script block. An error in that handler keeps the line instead of bringing PSReadLine down. Loading the hook again does not chain the handler twice.
  • bash: the Enter handler (bind -x) sets READLINE_LINE before accept-line, so bash records the new line only. The question is printed and read there.
  • zsh: __cdg_accept sets BUFFER before the original accept-line; the question uses zle -M and read -k.

Design and the spike behind it: the codigest/hanfix design note (Option B), checked before this code: PSReadLine history held back and re-added, and printing/reading inside bind -x with Enter, another key and Ctrl+C.

Also: $? inside a captured line (#55)

In PowerShell a captured line that read $? always saw True: __cdg runs a few statements of its own before the line, and each resets $?. The read-line now passes the status it saw at the prompt, and when that was a failure __cdg runs a function that sets $? to False (an error record written with ErrorAction Ignore: nothing printed, nothing in $Error) right before the line. Found in a live check of hanfix 0.2.7 with the real profile: ㅗㄹ -y re-ran a failing command and the next line printed True, while hf itself had set it to False (a line with a leading space, not captured, showed False). Separate commit, fixes #55.

Tests (tests/test_hook_terminal.py)

New test_pwsh_rewriter_replaces_the_line_before_it_runs and test_sh_rewriter_replaces_the_line_before_it_runs (bash, zsh), each with the rewriter loaded before and after the hook. A stand-in rewriter maps tags to rules:

  • replaced line runs, is captured under the new line with its output; PSReadLine and engine history (pwsh) or fc -l (bash/zsh) hold the new line once and never the typed one;
  • replaced with a line on the skip list: keeps the terminal, not captured;
  • ask + Enter runs the new line; ask + n runs nothing and leaves no history entry;
  • test_pwsh_captured_line_sees_the_previous_status (PowerShell hook: a captured line sees $? as True after a failed command #55): after a failing typed command, and after a rewriter's failing command, a captured line sees $? False and the exit code; after a line that succeeded, True. With the hook from main the same sequence gives True.
  • throw / error status, 300 ms sleep, a line that does not parse, a non-terminating error (pwsh) or stray output (sh): the typed line runs, and the rewriter's message is not on the screen.

local-ci: b790706 | docs and comments only since 325835e (README, README.ko.md, hook comments: the 200 ms check is not a timeout); at 325835e: Windows, ConPTY: pwsh rewriter tests 2 passed and the #55 test passed (at 325835e), existing pwsh terminal tests 5 passed (at 325835e); Git Bash 5.3 rewriter tests 2 passed and sh runner 2 passed + 1 xfailed (#52) (at 68f3d15; 325835e touches only hook.ps1); rest of the suite 296 passed, 1 skipped; same sequence as the live check, with hanfix 0.2.7 and this hook: $? False in all five cases (main's hook: True for captured lines); ruff 0.17.1 check and format; bash -n hook.sh; PowerShell parser on hook.ps1: 0 errors; conventions.py check: 0 findings | zsh (not installed here), pwsh/bash on Linux and macOS only in CI; Ctrl+C at the question not in the suite (checked in the spike, bash only); hanfix's rewriter is the next PR

@NaNIsaac
NaNIsaac merged commit a482b6d into main Oct 28, 2026
15 checks passed
@NaNIsaac
NaNIsaac deleted the feat/rewriters branch October 28, 2026 17:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

PowerShell hook: a captured line sees $? as True after a failed command

1 participant