Skip to content

feat(hook): print the snippet a shell evaluates to follow an apply - #80

Merged
torabit merged 7 commits into
mainfrom
torabit/feat/hook
Sep 8, 2026
Merged

torabit merged 7 commits into
mainfrom
torabit/feat/hook

Conversation

@torabit

@torabit torabit commented Sep 7, 2026

Copy link
Copy Markdown
Owner

Implements docs/hook.md, which #79 decides. Stacked on #79 — the base is
torabit/docs/hook, so this diff is the implementation only. Merge #79 first.

Part of #77.

What lands

vanadis hook <zsh|fish|bash> prints a snippet to evaluate. It sources the output of every
target marked shell, and registers a prompt hook that sources it again when that file's mtime
moves. shell is a new [[targets]] key; a value that names none of the three is a config
error rather than a target that silently never sources.

shell mtime prompt hook forks per prompt
zsh zstat from zsh/stat add-zsh-hook precmd none
fish path mtime --on-event fish_prompt none
bash stat, written in at generation time PROMPT_COMMAND one

hook reads config.toml and nothing else: no theme resolved, no state file read, themes/
not scanned. Every failure leaves stdout empty, so a config that will not load cannot leave half
a snippet in the user's shell.

How it is tested

tests/hook.rs starts a real interactive shell, feeds it the snippet, and reads what the shell
prints back. Asserting the snippet as a string would establish that the generator did not change
and nothing about whether zsh can parse the result. Five behaviours per shell: sources at
evaluation, sources again after a cycle, one hook after a second evaluation, the exit status
survives, and an apply --only naming no shell target re-sources nothing.

CI installs zsh and fish. The tests skip a shell that is not installed and refuse to skip when
CI is set
, so deleting the install step fails the run instead of quietly passing it.

zsh and bash were also driven by hand on this machine before the tests were written. fish is not
installed here, so the fish snippet is first executed by CI on this PR.

Also in this PR

  • docs/examples/config.toml: the zsh palette target, the reference config's one target read
    from the environment, now carries shell = "zsh".
  • skills/vanadis/references/reload.md and SKILL.md: the fzf row now points at the hook
    instead of ending at exec zsh, with a section on marking a target and the rc line. Held back
    from docs(hook): decide how a sourced target follows an apply #79 because the skill ships to users and should not name a command a released binary does
    not have.
  • docs/hook.md: the fish spelling, vanadis hook fish | source, which is what fish users write
    for every tool in this shape.

🤖 Generated with Claude Code

https://claude.ai/code/session_011wxMB7uC4uxSJ2dfHi8ucf

torabit and others added 6 commits September 8, 2026 03:51
A target whose output a shell sources cannot be reached by `reload`. The shell
that has the wrong colours is vanadis's parent, and no process replaces its
parent's image, so `exec zsh` is unreachable however it is spelled. fzf is the
entry `docs/config.md` names for this, and `LS_COLORS`, `GREP_COLORS` and a
pager named through a variable are the same shape.

`vanadis hook <shell>` prints a snippet to `eval`, the way direnv, mise,
starship and zoxide do. The snippet sources the outputs of the targets marked
`shell = "zsh"` and registers a prompt hook that sources each again when its
own mtime has moved. The state file is not the signal: `apply --only nvim`
moves it without touching a shell target, and watching it would put
`$XDG_STATE_HOME` and a file format into a snippet that otherwise needs only a
path.

The decisions the document records: a per-target `shell` key rather than a
top-level table, because a table naming targets is a second place a name is
written; paths baked into the snippet rather than a config parse per prompt,
with re-`eval` as the answer to staleness; zsh and fish detecting a change with
no fork, and bash spending one `stat`, with the `stat` chosen when the snippet
is generated rather than branched at runtime; a stamp file compared with `-nt`
rejected for bash because cleaning it up needs the one `EXIT` trap the user
also has; one hook after a second `eval`, and the exit status restored so a
prompt that shows it is not lied to.

The zsh row of the `reload` table now says what does reach it. The same row in
the shipped skill, and the CI that runs the snippet under each shell, land with
the implementation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wxMB7uC4uxSJ2dfHi8ucf
The state file as the signal, a top-level table, and a config read at prompt
time are each argued where they come up. Restating them under Rejected
alternatives said the same thing twice and made the section long enough to read
as the point of the document. What is left is the five the body never raises.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wxMB7uC4uxSJ2dfHi8ucf
`docs/hook.md` decides this. A target whose output a shell sources cannot be
reached by `reload`, because the shell holding the stale colours is vanadis's
parent. `vanadis hook <zsh|fish|bash>` prints a snippet to evaluate: it sources
the output of every target marked `shell`, and registers a prompt hook that
sources it again when that file's mtime moves.

The snippet holds resolved paths as literals and never runs vanadis again, so a
prompt costs no process and a shell that has lost vanadis from `$PATH` keeps
following. zsh reads the mtime with `zstat` and fish with `path mtime`, neither
forking; bash has no such builtin and spends one `stat`, written in at
generation time from the system vanadis is on rather than branched at runtime.

Three invariants hold in every shell. A second evaluation leaves one hook
registered, by `add-zsh-hook`, by redefining the fish handler, and by a
`PROMPT_COMMAND` append guarded on the function name. The hook returns the exit
status it found, so a prompt showing `$?` is not lied to. Every name it defines
is prefixed `_vanadis_`, and the zsh helper is emulated so a user's `setopt`
cannot change what it means.

`hook` reads `config.toml` and nothing else: no theme is resolved, no state file
is read, `themes/` is not scanned. Every failure leaves stdout empty, so a
config that will not load cannot leave half a snippet in the user's shell. A
config that loads and names no target for that shell reports on stderr and exits
zero, because the shell it is starting is otherwise fine.

`tests/hook.rs` evaluates what is printed in a real interactive shell and reads
what that shell prints back, because asserting the snippet as a string would
establish only that the generator did not change. CI installs the three shells,
and the tests refuse to skip a missing shell when `CI` is set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wxMB7uC4uxSJ2dfHi8ucf
fish emits `fish_prompt` only while drawing a prompt, and it draws none when
its input is a pipe, `-i` or not. The three tests that need a prompt therefore
passed nothing to the hook and read the colours the evaluation itself had
sourced. zsh and bash run precmd and `PROMPT_COMMAND` either way, which is why
they were green here and on this machine.

`script` allocates a terminal and connects the shell to it, so fish runs its
reader the way it does for a person. Only fish gets one: a terminal makes the
other two draw prompts into the output for nothing. What comes back carries
carriage returns, so they are dropped before the lines are read.

The handler probe falls back to `functions --handlers`, because the type filter
is the part of that command this has not run anywhere yet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wxMB7uC4uxSJ2dfHi8ucf
A terminal writes the prompt and the echo of what was typed around what the
script prints, so under fish the `bg=` marker is no longer at the start of its
line and the filter collected nothing. The two tests that used `contains` were
blind to this and passed; the one that compared the whole sequence failed.

Every test now compares the sequence of colours, found by scanning for `bg=`
followed by one. The echo of `bg=$VANADIS_BG` does not match it, which is what
makes the marker readable from the middle of a line.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wxMB7uC4uxSJ2dfHi8ucf
The three shells are not interchangeable to drive. fish emits `fish_prompt`
only while drawing a prompt and draws none for a pipe, so a piped fish answers
every question about a prompt with the state the evaluation itself left, and
answers it green. This cost two CI rounds to find and would cost them again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wxMB7uC4uxSJ2dfHi8ucf
Base automatically changed from torabit/docs/hook to main September 8, 2026 02:33
#79 was squashed into main, so `docs/hook.md` arrives from both sides as an
add. The branch holds the same document plus the two paragraphs this
implementation added to it: the fish spelling of the eval line, and why the
fish CI run needs a terminal. Resolved to the branch's copy, which is main's
copy with those two additions and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011wxMB7uC4uxSJ2dfHi8ucf
@torabit
torabit merged commit 4f71383 into main Sep 8, 2026
7 checks passed
@torabit
torabit deleted the torabit/feat/hook branch September 8, 2026 02:52
@torabit torabit mentioned this pull request Sep 8, 2026
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.

1 participant