From 86c3dc62479f3356d4ae7d1f2fa58876887e2369 Mon Sep 17 00:00:00 2001 From: torabit Date: Tue, 8 Sep 2026 03:51:40 +0900 Subject: [PATCH 1/6] docs(hook): decide how a sourced target follows an apply 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 ` 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) Claude-Session: https://claude.ai/code/session_011wxMB7uC4uxSJ2dfHi8ucf --- docs/config.md | 8 +- docs/hook.md | 257 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+), 1 deletion(-) create mode 100644 docs/hook.md diff --git a/docs/config.md b/docs/config.md index 28484ec..0a9372f 100644 --- a/docs/config.md +++ b/docs/config.md @@ -12,6 +12,8 @@ the file it was taken from. `[[targets]]` entry from a config file that already exists. [docs/schemes.md](schemes.md) decides the one path this document does not cover: the cache `vanadis remote update` writes the tinted-theming collection into. +[docs/hook.md](hook.md) decides how a target whose output a shell sources follows an apply, +which is the one case [`reload`](#reload) below cannot reach. ## Layout @@ -231,7 +233,11 @@ still reachable by writing a script and naming it here. | herdr, a terminal multiplexer | `herdr server reload-config` | yes | | starship, a shell prompt | next prompt | nothing to run | | nvim, btop, hunk, lazygit — an editor, a system monitor, a diff viewer, a git UI | restart the program | not a command | -| zsh with fzf, a shell and a fuzzy finder | `exec zsh` | no: it replaces the user's shell, and vanadis is a child process | +| zsh with fzf, a shell and a fuzzy finder | the shell sources the output again | no: `exec zsh` replaces the user's shell, and vanadis is a child process | + +The last row is the one `reload` cannot be spelled for at all, because the shell to fix is +vanadis's parent. [docs/hook.md](hook.md) decides `vanadis hook`, the prompt hook that lets such +a target follow an apply, and the `shell` key that marks it. So `reload` is optional and absent means nothing runs. It is not a hook system, and there is no `pre` counterpart: nothing in the corpus needs work done before a write. diff --git a/docs/hook.md b/docs/hook.md new file mode 100644 index 0000000..39c6999 --- /dev/null +++ b/docs/hook.md @@ -0,0 +1,257 @@ +# hook + +This document decides how a target whose output is read by a shell follows a theme change in a +shell that is already running. [docs/config.md](config.md) decides `config.toml`, the paths, +and `reload`, which runs a command after a write. That command cannot reach the shell that +started vanadis, and this is what covers the targets that need it to. + +## The problem + +`reload` runs argv directly, in a child process. The shell that has the wrong colours is +vanadis's parent, and no process can replace its parent's image. + +```toml +reload = ["exec", "zsh"] +``` + +That entry fails before reaching the question: `exec` is a shell builtin, and `reload` takes no +shell. Spelling it `["zsh", "-c", "exec zsh"]` runs a shell, replaces that shell with another +one, and exits. The user's shell is untouched either way. + +So the whole class of targets whose output is *sourced* rather than *read from a path* has no +way to follow an apply. fzf is the entry [docs/config.md](config.md#reload) names, because it +takes its colours from `FZF_DEFAULT_OPTS` in the environment. `LS_COLORS`, `GREP_COLORS` and a +pager named through a variable are the same shape. The count matters: this is not one tool with +an awkward interface, it is every tool configured through the environment. + +The knowledge the fix needs is vanadis's, not the user's. Where the sourced file is, how to +detect a change without forking, and the per-shell syntax for registering a prompt hook are all +things `config.toml` already holds or vanadis already knows. Written by hand into a `.zshrc`, +that knowledge is copied, not referenced, and cannot be corrected when a path moves. + +## The mechanism + +``` +vanadis hook +``` + +Prints a snippet to stdout, to be evaluated by the shell it names: + +```zsh +eval "$(vanadis hook zsh)" +``` + +The snippet sources the outputs of the targets that name that shell, once at evaluation, and +registers a prompt hook that sources each of them again when its file has changed. This is the +shape `direnv hook zsh`, `mise activate zsh`, `starship init zsh` and `zoxide init zsh` already +have, and a user who has one of them in an rc file recognises the line without being taught it. + +**`hook`, not `init`.** `vanadis init` is the command that turns a config file that already +exists into a template, a theme and a target ([docs/init.md](init.md)). The name is taken, and +taking it twice for two unrelated jobs is worse than differing from starship. + +**Every failure prints nothing on stdout.** A config that will not load, a shell name that is +not one of the three, a target whose `output` cannot be resolved: all of them report on stderr +and exit non-zero with stdout empty. `eval` of an empty string is a no-op, so a failure cannot +leave a half-written snippet in the user's shell. That is +[Order](config.md#order)'s guarantee in the shape a command that writes to a shell can have it. + +`hook` reads `config.toml` and nothing else. It resolves no theme, reads no state file and does +not scan `themes/`, because the snippet holds paths and no colours. A machine that has applied +nothing yet emits the same snippet as one that has. + +## The change signal is the output's own mtime + +Not the state file's, for three reasons. It is direct: the file the shell sources is the file +whose change matters. `apply --only nvim` writes the state file without touching a shell +target's output, and watching the state file would re-source for nothing. And the snippet then +needs no knowledge of `$XDG_STATE_HOME` or of the state file format, only of the output path, +which it has to hold anyway. + +**A missing output sources nothing and is not an error.** The hook runs on every prompt, so +anything it says, it says forever. An output that has never been applied is the ordinary state +of a machine between a `git clone` and a first `vanadis apply`. The recorded mtime stays empty +until the file appears, at which point it differs and the file is sourced. + +**The resolution is one second.** `zstat +mtime` and `path mtime` both report seconds. Two +applies inside the same second as the shell's last observation are seen as one, and the shell +keeps the colours of the first until the next apply moves the mtime again. Reaching for +sub-second stamps to close that is not done: the gap needs two applies in the same second, and +a prompt hook only fires when a person is at the keyboard. + +## Saying a target is sourced + +A per-target key, beside `reload`: + +```toml +[[targets]] +name = "fzf" +template = "templates/fzf/colours.zsh.in" +output = "~/.config/fzf/colours.zsh" +shell = "zsh" +``` + +| key | required | value | +| --- | --- | --- | +| `shell` | no | `zsh`, `fish` or `bash`: the shell whose `hook` sources this output | + +Any other value is rejected when the config loads, the way every other enumerated value in the +file is. A typo is then a config error and not a target that silently never sources. + +**A shell name, not a boolean.** The output is a file in that shell's syntax; a `.zshrc` +fragment is not something fish can source. A user with both shells keeps a zsh target and a +fish target side by side, rendered from two templates, and each `hook` sources its own. A +boolean would make that config unsayable and would leave `vanadis hook fish` guessing. + +**Per target, not a top-level table.** A `[shell]` table listing target names would be a second +place where a target's name is written, which is what +[docs/config.md](config.md#layout) declined for themes and for the same reason: two places +that can disagree. `shell` sits where `reload` sits, because it answers the same question — how +a write reaches the tool — for the targets `reload` cannot answer it for. + +**`shell` and `reload` are independent.** Both may be present. `reload` runs in vanadis's +process tree at apply time; `shell` reaches a shell vanadis is a child of, at that shell's next +prompt. Nothing about one implies the other. + +Several targets may name the same shell. They are sourced in the order they appear in +`config.toml`, which is the order [Order](config.md#order) already gives writes and reloads. +Each is compared and sourced on its own, so a change to one does not re-source the others. + +## The snippet + +Three invariants, in every shell that ships. + +**A second `eval` leaves one hook registered.** Re-evaluating the snippet is how a user picks +up a config change ([below](#the-paths-are-baked-in)), so it has to be free. zsh gets it from +`add-zsh-hook`, which deduplicates by function name. fish gets it from redefining the handler +function, which replaces the old one along with its event binding. bash has neither, and guards +the `PROMPT_COMMAND` append by testing for the function name first. + +**The hook leaves the exit status it found.** A prompt that shows the last command's status +reads `$?` after the hook has run. A hook that returns the status of its own `stat` call makes +every failed command look successful, or the reverse. The status is saved on entry and returned +on exit. + +**The hook touches nothing the user named.** Every function and variable it defines is prefixed +`_vanadis_`. The zsh function runs under `emulate -L zsh`, so a user's `setopt` does not change +what the snippet means. + +### Per shell + +| shell | mtime | prompt hook | forks per prompt | +| --- | --- | --- | --- | +| zsh | `zstat -A ... +mtime` from `zsh/stat` | `add-zsh-hook precmd` | none | +| fish | `path mtime` | `function ... --on-event fish_prompt` | none | +| bash | `stat` | `PROMPT_COMMAND` | one | + +**bash forks once per prompt.** It has no builtin that reads an mtime. Which `stat` to write is +settled when the snippet is generated, not when it runs: vanadis knows the system it is on, so +the emitted snippet carries `stat -c %Y` or `stat -f %m` and no runtime branch. The cost is one +fork per prompt, which is less than the `PROMPT_COMMAND` of anyone who displays a git branch. + +The alternative that avoids the fork is rejected [below](#rejected-alternatives). Shipping bash +with a source-at-startup snippet and no following is rejected too: a bash user who runs +`vanadis cycle` in another terminal is exactly the person this document exists for, and half an +answer here would have to be explained everywhere `hook` is mentioned. + +**The recorded mtime is one variable per target**, named by the target's position, not an +associative array keyed by path. bash 3.2 is the bash macOS ships and it has no associative +arrays. The paths are baked in, so their positions are too, and nothing is lost. + +### When no target names the shell + +`vanadis hook fish` on a config with no fish target prints nothing on stdout, one line on +stderr, and exits zero. `eval` reads stdout, so the shell starts with no hook and the user sees +why. Exiting non-zero was considered: it makes `$?` immediately after the `eval` line non-zero +for a shell that started correctly, which is a worse thing to hand a `.zshrc` than a message. + +A shell name that is not one of the three is refused by the argument parser, before any file is +read. It is the same treatment `--variant` gives a background that is not `light` or `dark`. + +## The paths are baked in + +The emitted snippet holds resolved output paths as literals. It does not read `config.toml`, +and it does not run vanadis. + +The cost is a snippet that goes stale when a target's `output` moves or a `shell` key is added. +Re-evaluating is the fix, and it is the user's to run — the same answer direnv, mise, starship +and zoxide give for the same staleness. It is a good trade twice over. A config parse per +prompt is a `vanadis` process per prompt, which is what +[docs/config.md](config.md#querying) already refused for `get` when it declined to scan +`themes/` for a command a prompt hook calls. And a baked snippet keeps working in a shell whose +`$PATH` no longer has vanadis on it, which a shell in a container or a rescue environment +routinely does not. + +## What is sourced is the user's + +The file the hook sources is whatever the user's template rendered. vanadis does not parse it, +does not check that it is valid shell, and does not sandbox it. A template that renders a +syntax error breaks every shell started after the next apply, and the fix is to fix the +template. + +That is the same trust `eval "$(vanadis hook zsh)"` already grants, stated out loud because the +indirection hides it: the user evaluates one line, and what runs is a file written by a +different command at a different time. + +It also sets what belongs in a shell target's template. Exports and variable assignments follow +the theme. A template that starts a program or writes a file is a template that does it in +every new shell. + +## Checking the snippet in CI + +The snippet is generated text that is only correct if a shell accepts it. CI evaluates it under +each of the three shells and asserts the behaviour, rather than comparing it against an +expected string: a string assertion establishes that the generator did not change, and says +nothing about whether zsh can parse the result. + +What is asserted, per shell: evaluating the snippet sources the output; evaluating it twice +registers one hook; touching the output re-sources it at the next prompt; leaving the output +alone does not; and the exit status of the command before the prompt survives. + +This is also what fixes the versions claimed. `path mtime` is not in every fish that is +installed anywhere, and the fish CI runs on is the fish this document claims. + +## Rejected alternatives + +**Watching the state file.** Covered above: it changes when a target that is not this one is +applied, and it makes the snippet depend on a path and a file format it otherwise never needs. + +**A top-level `[shell]` or `[hook]` table.** A second place naming targets, and it cannot hold +two shells without becoming the per-target key with extra steps. + +**Reading `config.toml` at prompt time.** A fork and a TOML parse per prompt, to remove a +staleness that one `eval` fixes. + +**A stamp file per shell, compared with `[[ output -nt stamp ]]`.** bash's `-nt` is a builtin +and `: > "$stamp"` updates a stamp without forking, so this does reach zero forks per prompt. +It needs a stamp per shell process, since a shared one would let the first shell to notice a +change stop every other shell from noticing it. That means a file under a runtime directory +named by `$$`, and an `EXIT` trap to remove it. bash has one `EXIT` trap, and installing ours +silently replaces the user's. A fork per prompt is a smaller thing to spend than that. + +**vanadis emitting the exports itself**, as `eval "$(vanadis env zsh)"` printing +`export FZF_DEFAULT_OPTS=...` derived from tokens. It removes the rendered file and with it the +mtime question. It also needs vanadis to know which environment variables which tools read, +which is a list of tools — and vanadis carries no list of tools. The template already expresses +this and expresses more of it: `FZF_DEFAULT_OPTS` is one string a user assembles from many +tokens, in an order only they know. + +**One aggregated file that every shell target renders into.** It would let the snippet hold one +path. Nothing in `[[targets]]` would name that file, so `check` could not compare it against +anything, and vanadis would be writing an output it does not otherwise account for. + +**A daemon watching outputs with inotify.** It fixes the fork and the one-second resolution +together, and costs a process to start, supervise and stop, plus a decision about what happens +to a shell whose daemon died. A prompt hook has no lifetime beyond the shell it is in. + +**`vanadis hook --install`, editing the user's rc file.** The line to add is one line, and a +tool that writes into `.zshrc` has to decide how to find the file, where in it to write, and +what to do when the user has moved the line. Printing the line and letting the user place it is +what every tool in this shape does. + +## Acceptance + +- `eval "$(vanadis hook zsh)"` in one shell, `vanadis cycle` in another, and the first shell's + `FZF_DEFAULT_OPTS` holds the new theme's colours at its next prompt, with no `exec zsh`. +- A second `eval` in the same shell leaves one hook registered, not two. +- `vanadis apply --only nvim`, where `nvim` is not a shell target, re-sources nothing. From 8d0e02c8bc74d0444b8c929eb4896d4e2c568d1b Mon Sep 17 00:00:00 2001 From: torabit Date: Tue, 8 Sep 2026 03:54:19 +0900 Subject: [PATCH 2/6] docs(hook): drop the rejected alternatives the body already decides 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) Claude-Session: https://claude.ai/code/session_011wxMB7uC4uxSJ2dfHi8ucf --- docs/hook.md | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/docs/hook.md b/docs/hook.md index 39c6999..f39527e 100644 --- a/docs/hook.md +++ b/docs/hook.md @@ -213,14 +213,8 @@ installed anywhere, and the fish CI runs on is the fish this document claims. ## Rejected alternatives -**Watching the state file.** Covered above: it changes when a target that is not this one is -applied, and it makes the snippet depend on a path and a file format it otherwise never needs. - -**A top-level `[shell]` or `[hook]` table.** A second place naming targets, and it cannot hold -two shells without becoming the per-target key with extra steps. - -**Reading `config.toml` at prompt time.** A fork and a TOML parse per prompt, to remove a -staleness that one `eval` fixes. +The state file as the signal, a top-level table naming shell targets, and a config read at +prompt time are each decided in the section above that raises them. **A stamp file per shell, compared with `[[ output -nt stamp ]]`.** bash's `-nt` is a builtin and `: > "$stamp"` updates a stamp without forking, so this does reach zero forks per prompt. From 7b0d768a005250d7ac959853ddf1c0a5d93b5bc7 Mon Sep 17 00:00:00 2001 From: torabit Date: Tue, 8 Sep 2026 04:05:25 +0900 Subject: [PATCH 3/6] feat(hook): print the snippet a shell evaluates to follow an apply `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 ` 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) Claude-Session: https://claude.ai/code/session_011wxMB7uC4uxSJ2dfHi8ucf --- .github/workflows/ci.yml | 5 + docs/examples/config.toml | 8 +- docs/hook.md | 7 + skills/vanadis/SKILL.md | 5 +- skills/vanadis/references/reload.md | 45 ++- src/config.rs | 112 ++++++- src/hook.rs | 284 ++++++++++++++++ src/lib.rs | 4 +- src/main.rs | 59 +++- tests/config.rs | 15 +- tests/fixtures/hook/config.toml | 36 ++ tests/fixtures/hook/templates/fish.in | 1 + tests/fixtures/hook/templates/posix.in | 1 + tests/fixtures/hook/themes/nord.toml | 46 +++ tests/fixtures/hook/themes/paper-light.toml | 46 +++ tests/hook.rs | 348 ++++++++++++++++++++ 16 files changed, 1012 insertions(+), 10 deletions(-) create mode 100644 src/hook.rs create mode 100644 tests/fixtures/hook/config.toml create mode 100644 tests/fixtures/hook/templates/fish.in create mode 100644 tests/fixtures/hook/templates/posix.in create mode 100644 tests/fixtures/hook/themes/nord.toml create mode 100644 tests/fixtures/hook/themes/paper-light.toml create mode 100644 tests/hook.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cac15a8..cf29deb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,6 +24,11 @@ jobs: - name: clippy run: cargo clippy --all-targets -- -D warnings + # tests/hook.rs evaluates the emitted snippet in each shell `vanadis hook` claims, and + # refuses to skip a shell when `CI` is set. + - name: shells + run: sudo apt-get update && sudo apt-get install -y zsh fish + - name: test run: cargo test --all-targets diff --git a/docs/examples/config.toml b/docs/examples/config.toml index 489bd47..98ba332 100644 --- a/docs/examples/config.toml +++ b/docs/examples/config.toml @@ -80,10 +80,12 @@ name = "starship" template = "templates/starship/starship.toml.in" output = "~/.config/starship.toml" -# fzf reads these colours from the environment, so the shell has to be replaced for a change -# to show. `exec zsh` replaces the user's shell and vanadis runs as a child process, so it -# cannot be a reload command. +# fzf reads these colours from the environment, so a write is not enough on its own: the +# shell has to source the file again. No `reload` can do that, because the shell that needs +# fixing is vanadis's parent. `shell` marks the target instead, and the prompt hook that +# `eval "$(vanadis hook zsh)"` registers sources the file again whenever it changes. [[targets]] name = "zsh" template = "templates/zsh/palette.zsh.in" output = "~/.config/zsh/palette.zsh" +shell = "zsh" diff --git a/docs/hook.md b/docs/hook.md index f39527e..ac94fd3 100644 --- a/docs/hook.md +++ b/docs/hook.md @@ -41,6 +41,13 @@ Prints a snippet to stdout, to be evaluated by the shell it names: eval "$(vanadis hook zsh)" ``` +fish reads it the way fish reads every tool in this shape, because `eval` there would need the +output collected into one argument first: + +```fish +vanadis hook fish | source +``` + The snippet sources the outputs of the targets that name that shell, once at evaluation, and registers a prompt hook that sources each of them again when its file has changed. This is the shape `direnv hook zsh`, `mise activate zsh`, `starship init zsh` and `zoxide init zsh` already diff --git a/skills/vanadis/SKILL.md b/skills/vanadis/SKILL.md index 835d6ac..d73bbb4 100644 --- a/skills/vanadis/SKILL.md +++ b/skills/vanadis/SKILL.md @@ -63,6 +63,7 @@ not travel with it. | `vanadis get --json` | the whole resolved theme, flat, keyed by token path | | `vanadis render