From 21a02fd0e40e4d90a288c09f9204c95e0f87b5cc Mon Sep 17 00:00:00 2001 From: Rootiest Date: Mon, 7 Sep 2026 03:06:30 -0400 Subject: [PATCH 1/9] docs(specs): add header-driven --help design Design for making the man-page-style comment header above each function reachable at runtime via -h/--help, instead of hand-writing 40 more menus. One renderer (__fish_help_header) parses the .fish source at call time and prints the header to stdout; each call site is a single line. Audits all 79 published functions that lack help today into 35 shadow/pass-through exempt, 4 prompt-hook exempt, and 40 to convert, with a one-line reason each. Corrects the baseline counts (109 published functions, 30 with menus, 79 without) and records two pre-existing defects found while auditing. Note: AGENTS/ is gitignored upstream, so this file is force-added. --- .../2026-09-07-header-driven-help-design.md | 446 ++++++++++++++++++ 1 file changed, 446 insertions(+) create mode 100644 AGENTS/specs/2026-09-07-header-driven-help-design.md diff --git a/AGENTS/specs/2026-09-07-header-driven-help-design.md b/AGENTS/specs/2026-09-07-header-driven-help-design.md new file mode 100644 index 0000000..dc259d0 --- /dev/null +++ b/AGENTS/specs/2026-09-07-header-driven-help-design.md @@ -0,0 +1,446 @@ +# Design: Header-Driven `--help` + +**Date:** 2026-09-07 +**Status:** Proposed +**Job:** 3 of the parallel improvement effort (fork of `08e66c8`, branch `work`) + +--- + +## Overview + +`functions/CLAUDE.md` requires every user-facing function to accept `-h`/`--help` +and print a formatted menu to stdout, "unless it strictly wraps another tool's +help." Most do not. + +The documentation those menus would contain already exists. Every documented +function carries a man-page-style comment header — `SYNOPSIS`, `DESCRIPTION`, +`ARGUMENTS`, `EXIT STATUS`, `RETURNS`, `EXAMPLE`, `NOTES` — which +`2026-07-26-function-headers-as-ssot-design.md` established as the SSOT for +Section 5 of the manual, and which `docs/verify-manual.py` already enforces. + +The header is written; it is simply not reachable at runtime. This spec makes it +reachable with one renderer function and one line per call site. It authors no +new documentation prose, and it changes nothing in the docs build pipeline. + +### Why not write the menus + +Writing a menu per function creates a second copy of facts the header already +holds, in a place no verifier checks. The 40 functions in scope would cost +roughly 600 lines of `echo` that immediately begin drifting from the header that +generates the manual. The 30 functions that already have hand-rolled menus are +the evidence: several of them already say things their headers do not. + +--- + +## Measured Baseline + +Taken 2026-09-07 against `functions/` at `08e66c8`. Counts come from +`manualtools.parse_functions()`, not from grep, so they match what the manual +actually publishes. + +| Fact | Value | +|---|---| +| `.fish` files in `functions/` | 185 | +| Files with a `# CATEGORY` comment | 110 | +| Functions the parser actually publishes | **109** | +| ...that print their own help menu today | **30** | +| ...that do not | **79** | +| Of those 79: exempt (see §4) | 39 | +| Of those 79: to convert | 40 | +| `fish tests/run-tests.fish` | 317/317 pass | +| `python3 docs/verify-manual.py` | 74/74 pass | + +The job brief quoted 110 / 34 / 78. Three corrections, all verified: + +- **110 → 109.** `zoxide.fish` carries two `# CATEGORY` blocks, but both resolve + to `_zoxide_*` names, which `parse_functions` drops as private. It publishes + nothing. +- **34 → 30.** The grep for `--help` counted `fisher.fish` and + `_fzf_configure_bindings_help.fish` (neither is a published function), and + `jr.fish` and `yt-dlp.fish`, which mention `--help` only inside their comment + headers and do not handle it in the body. +- **78 → 79.** `split` matched the grep because it uses `-h` for + `--horizontal`. It has no help menu, and its `-h` is spoken for. It belongs in + the target set as a special case (§4.3). + +### Pre-existing defects found, not fixed here + +- **`dops.fish` defines `function docker`, not `dops`.** The single header block + in that file resolves to the file stem, so the manual publishes an entry for + `dops` — a function that does not exist — while `docker` is undocumented. The + renderer resolves this correctly by accident (§3.2), but the manual entry + stays wrong. Out of scope: fixing it means authoring header content. +- **`sponge_filter_secrets.fish`** has a blank line between its header block and + its `function` line. The renderer tolerates it (§3.2); noted because it is the + only file that does. + +--- + +## 1. Requirements + +1. Every non-exempt published function responds to `-h` and `--help` with its own + documentation on **stdout**, exit 0. +2. Functions that pass `--help` through to a wrapped tool keep doing so. +3. `fish tests/run-tests.fish` stays at 317/317 plus the new checks. +4. `python3 docs/verify-manual.py` passes unchanged. +5. A test fails when a new user-facing function ships without help. +6. The docs build pipeline is not modified. +7. No header documentation content is added or rewritten. + +--- + +## 2. Renderer: `__fish_help_header` + +One new autoloaded function, `functions/__fish_help_header.fish`. + +```fish +__fish_help_header [args...] +``` + +It returns **1 — and prints nothing — only when `args[1]` is not a help flag.** +In every other case it prints something useful to stdout and returns 0. That +asymmetry is the safety-critical invariant: a return of 1 means "carry on with +the real work", so a parse failure must never produce it. `upgrade --help` +falling through to `paru -Syu --noconfirm` is the failure this rule exists to +prevent. + +### 2.1 Call site + +One line, as the **first statement of the function body**, above any opinionated +guard: + +```fish +function poke --description 'touch with automatic parent directory creation' + __fish_help_header (status current-function) $argv; and return 0 + ... +end +``` + +`status current-function` is evaluated in the caller's scope, so it yields the +caller's name. Placing the line above the C1/C4 guard means documentation stays +reachable when a component is disabled, and guarantees nothing side-effecting +runs before the flag is inspected. + +The `; and return 0` form leaves `$status` at 1 when no help flag was passed. +No function in scope reads `$status` as its first act, so this is inert — but +new call sites must not be added above a `$status` read. + +### 2.2 First-argument-only interception + +The renderer fires only when **`$argv[1]`** is `-h` or `--help`. Not anywhere in +`$argv`. + +This is not a simplification, it is a correctness requirement. `bkg`, +`wake-lock`, `split` and `spwin` take a command to run as their arguments. +Scanning all of `$argv` would make `wake-lock rsync --help` print `wake-lock`'s +help instead of running rsync. First-argument-only is also the pattern +`jobrunner` and `rand_string` already use. + +The 30 existing argparse-based menus keep accepting `--help` in any position. +This spec does not touch them (§5). + +### 2.3 Output format + +Matches `config-help`, the richest and most recent house menu: + +``` + bold + +USAGE bold brblue + two-space indent, deeper indent preserved + +DESCRIPTION + ... + +ARGUMENTS + ... +``` + +- `SYNOPSIS` renders as `USAGE`; `EXAMPLE` renders as `EXAMPLES`. Every other + label renders verbatim. +- `CATEGORY`, `COMPONENT` and `DEPENDENCIES` are build metadata and are skipped. +- `EXIT STATUS`, `RETURNS` and `NOTES` render when present. +- Blank lines inside a section are preserved (20 headers have multi-paragraph + `DESCRIPTION`s); trailing blanks are trimmed, mirroring + `manualtools._trailing_blanks`. +- **Body text is not colorized.** Only the name and the section headings carry + color. Guessing which words in a `DESCRIPTION` are flags or commands is a + heuristic with no upside; `config-help`'s hand-tuned coloring stays hand-tuned. + +### 2.4 Degraded output + +If `functions -D` yields no readable file, or the file holds no renderable +section, print the function name, its `--description`, and a pointer to +`help config ` — then return 0. Never return 1. + +--- + +## 3. Where the help text comes from + +### 3.1 Decision: parse the `.fish` source at call time + +Rejected alternatives: + +- **Read the generated `docs/fish-config.md`.** It is committed and + pre-formatted, but it is a *generated* artifact: a header edited without a + docs rebuild makes `--help` lie, which is the exact drift this job exists to + remove. It also costs a 163 KB scan and a `### ` heading match that can + collide with prose headings elsewhere in the manual. +- **Precompute a fish data file at build time**, mirroring the component + registry. This is the fastest option at runtime, but it adds a build artifact, + a new staleness class, and a step in the docs pipeline — which requirement 6 + forbids touching. +- **Reuse `config-help`.** It is a pager launcher over the whole manual; it + cannot emit one function's entry to stdout, and bending it into that shape + would cost more than the renderer. + +Source parsing wins because the source cannot be stale, the parse is ~15 lines +of `string match`, and the cost is one small file read on a keypress — never at +startup. + +### 3.2 Parsing rule + +Locate `function ` in the file, then walk **backwards** collecting the +contiguous run of `#` lines above it (skipping a blank separator line, for +`sponge_filter_secrets`). Within that run, apply `manualtools`' own grammar: + +- `^#\s+([A-Z][A-Z ]*[A-Z])\s*$` starts a section. +- Any other `#` line is body: strip `#`, then strip exactly three leading spaces + if present, so deeper indentation in nested `ARGUMENTS` tables survives. +- `#` lines before the first label (the copyright preamble) are ignored. + +Backward-walking from the `function` line replaces `manualtools._block_identity` +and is strictly more accurate at runtime: it resolves the three multi-header +files that publish a function (`fish-deps`, `gi`, `y`) correctly, and it gives +`docker` its real header in `dops.fish` where the Python parser attributes that +block to the file stem. + +### 3.3 Verified mechanics + +Proven in fish 4.9.1, not assumed. An autoloaded function calling +`(status current-function)` and `functions -D` on the result resolves to its own +defining file, both when called directly and when called from another function: + +``` +status current-function: probefn +functions -D self: .../functions/probefn.fish +status filename: .../functions/probefn.fish +``` + +Called *inside* the renderer, `status current-function` returns `__fish_help_header` +and `status stack-trace` returns the caller's path only as `~`-abbreviated prose. +Hence the name is passed as an argument rather than discovered. A working +prototype rendered `poke --help` in the format of §2.3 and left `poke`'s normal +path untouched. + +--- + +## 4. Classification of the 79 + +The exemption in `functions/CLAUDE.md` is "strictly wraps another tool's help." +Applied literally it splits into two classes, plus one collision case. + +**EXEMPT-A — shadow or pass-through.** The function shadows a real same-named +binary, or its entire body forwards `$argv` to one named tool. `ls --help` must +reach `ls`. Intercepting is a regression, and for the C1-guarded shadows it also +breaks the disabled-fallback contract, where the bare tool is supposed to answer. + +**EXEMPT-B — not a command.** Invoked by fish, never typed. Published in the +manual, but `--help` is meaningless. + +### 4.1 EXEMPT-A (35) + +| Function | Reason | +|---|---| +| `agy` | Shadows `agy`; body ends in `command agy $argv`. | +| `antigravity-ide` | Shadows `antigravity-ide`; only filters one stderr line. | +| `bash` | Shadows `bash`; adds `--rcfile`, forwards the rest. | +| `cat` | Shadows `cat`; C1 guard falls back to `command cat $argv`. | +| `cdi` | Whole body is `zi $argv`; zoxide owns the help. | +| `cffetch` | `clear` then `fastfetch $argv`. | +| `cheat` | `command cheat -c $argv`, else `tldr`/`man`. | +| `claude` | Shadows `claude`; body ends in `command claude $argv`. | +| `clone` | Whole body is `clone-in-kitty $argv`. | +| `clonet` | Whole body is `clone-in-kitty --type=tab $argv`. | +| `config-toggle` | Deprecated alias; forwards to `config-settings`, which has help. | +| `copy` | Whole body is `command cp $argv` with one directory special case. | +| `dops` | The file defines `docker`, which shadows `docker`. | +| `du` | Shadows `du`; unmatched args reach `command du`. | +| `dusize` | `du -sh $argv[1]` through the `du` shadow. | +| `fast-cli` | Whole body is `command fast $argv`. | +| `ffetch` | Whole body is `fastfetch $argv`. | +| `gitui` | `command gitui -t frappe.ron $argv`. | +| `gitup` | Its own `SYNOPSIS` is `gitup [args...]`, forwarded to `git fetch`. | +| `jr` | Whole body is `jobrunner $argv`; `jobrunner` has a help menu. | +| `joplin` | Whole body is the real `joplin` binary with `NODE_OPTIONS` set. | +| `less` | Shadows `less`; resolves a pager and forwards `$argv`. | +| `ls` | Shadows `ls`; forwards to `eza`/`lsd`/`command ls`. | +| `mkdir` | Shadows `mkdir`; any flag argument goes to `command mkdir -p`. | +| `mv` | Shadows `mv`; falls through to `command mv $argv`. | +| `paste` | Shadows coreutils `paste`; forwards `$argv` to `wl-paste`/`xclip`. | +| `ping` | Shadows `ping`; forwards to `prettyping` or `command ping`. | +| `rawfish` | Whole body is `env NO_TMUX=1 fish $argv`. | +| `rg` | Shadows `rg`; adds one flag under Kitty, forwards the rest. | +| `rm` | Shadows `rm`; unmatched args reach `command rm $argv`. | +| `search` | Whole body is `$aur $argv` (paru or yay). | +| `ssh` | Shadows `ssh`; forwards to `kitten ssh` or `command ssh`. | +| `top` | Shadows `top`; forwards to `btop` or `command top`. | +| `view` | Shadows vim's `view`; whole body is `nvim -R $argv`. | +| `yt-dlp` | Shadows `yt-dlp`; injects defaults, forwards `$argv` last. | + +### 4.2 EXEMPT-B (4) + +| Function | Reason | +|---|---| +| `fish_prompt` | Prompt hook; fish calls it, users never do. | +| `fish_right_prompt` | Prompt hook. | +| `fish_mode_prompt` | Prompt hook; body is empty. | +| `sponge_filter_secrets` | `sponge` plugin filter callback, invoked per command. | + +### 4.3 CONVERT (40) + +All take the standard call site of §2.1 unless noted. + +| Function | Reason it is not exempt | +|---|---| +| `bd-pull` | Own Gitea/Beads logic; `$argv[1]` is a repo slug. | +| `bkg` | Command runner; `$argv` is a command, not flags. First-arg rule applies. | +| `branch` | Own git logic; `branch --help` currently feeds `--help` to `git checkout -b`. | +| `check_fish_deps` | Ignores `$argv`; runs `fish-deps status`. | +| `claude-docs` | Ignores `$argv`; fires a fixed prompt. | +| `claude-pr` | Ignores `$argv`; fires a fixed prompt. | +| `cleanup` | Ignores `$argv`; `--help` currently runs `sudo pacman -Rns`. | +| `fast` | Ignores `$argv`; placeholder that prints a notice. | +| `fc` | Own history logic; `--help` currently becomes a history search term. | +| `fish-deps` | **Reuse:** wire `case -h --help` to the existing `__fish_deps_help`, return 0. Today `--help` hits `case '*'`, prints "Unknown subcommand", exits 1. | +| `fzf-update` | Ignores `$argv`; `--help` currently clones and installs fzf. | +| `gip` | Ignores `$argv`. | +| `gip4` | Ignores `$argv`. | +| `gip6` | Ignores `$argv`. | +| `hist` | Ignores `$argv`; opens an fzf picker. | +| `lD` | Not a binary; three-branch body whose value is the `--only-dirs` preset. | +| `ld` | Ignores `$argv`; launches lazydocker with a computed `DOCKER_HOST`. | +| `limine-edit` | Ignores `$argv`; `--help` currently runs `sudoedit` and re-enrolls Limine. | +| `lock` | Ignores `$argv`; `lock --help` currently locks the screen. | +| `lsr` | Not a binary; value is the reverse-time preset. | +| `lss` | Not a binary; value is the size-sort preset. | +| `lstree` | Not a binary; value is the recursive-tree preset. | +| `lt` | Not a binary; value is the depth-2 tree preset. | +| `ltr` | Not a binary; value is the reverse-time preset. | +| `lx` | Not a binary; value is the extension-sort preset. | +| `parur` | Ignores `$argv`; opens an fzf package picker. | +| `poke` | Own logic; `--help` would be created as a file. | +| `ports` | Ignores `$argv`; runs `sudo lsof`. | +| `qr` | `--help` would be encoded into a QR code. | +| `sbver` | Own `--brief` flag; runs `sudo sbctl verify`. | +| `screensleep` | Ignores `$argv`; `--help` currently blanks the display. | +| `split` | **`--help` only, no `-h`.** Its own `ARGUMENTS` documents `-h, --horizontal`. | +| `spwin` | Own terminal dispatch; first-arg rule protects `spwin --help`. | +| `steam-dl` | Ignores `$argv`. | +| `sudo-toggle` | Ignores `$argv`; `--help` currently rewrites `/etc/sudoers.d`. | +| `swapstat` | Ignores `$argv`. | +| `tab` | Own terminal dispatch; first-arg rule protects the forwarded command. | +| `tmux-clean` | Ignores `$argv`; `--help` currently kills tmux sessions. | +| `upgrade` | Ignores `$argv`; `--help` currently runs `paru -Syu --noconfirm`. | +| `wake-lock` | Command runner; first-arg rule protects `wake-lock --help`. | + +Eight of these currently perform a destructive or irreversible action when handed +`--help`, because they ignore `$argv` entirely and just run: `cleanup` +(`sudo pacman -Rns`), `fzf-update` (clone and install), `limine-edit` (`sudoedit` +and re-enroll), `lock` (locks the session), `screensleep` (blanks the display), +`sudo-toggle` (rewrites `/etc/sudoers.d`), `tmux-clean` (kills tmux sessions) and +`upgrade` (`paru -Syu --noconfirm`). For these, converting is a safety fix before +it is a documentation one. + +`split` is the only `-h` collision in the whole set; every other CONVERT function +was checked and uses `-h` for nothing. + +--- + +## 5. The 30 existing menus stay + +Not converted in this job. The risk is asymmetric: + +- Three of them carry content that **does not exist in any header**, so + conversion would delete documentation: `config-help`'s ov navigation keys and + pager fallback chain, `qc`'s appended `aichat --help`, `superpowers`' inline + usage block. Recovering them means authoring header prose, which requirement 7 + forbids. +- Thirteen use `argparse`/`_flag_help` and accept `--help` in any position. + Moving them to the first-argument-only rule of §2.2 is a real behavior change + for menus people already use. +- No acceptance criterion requires it. The duplication they represent is + pre-existing and static — it does not grow as new functions land, because new + functions will use the renderer. + +Thirty conversions is thirty independent chances to change output somebody +relies on, bought for no test. Recommended as a separate follow-up branch, where +each diff can be reviewed against its own before/after output. + +--- + +## 6. Testing + +Two additions to `tests/functional.fish`, which runs inside the sandboxed loaded +session where `functions -D` resolves to the sandbox copy. + +**`test_help_renderer`** — behavioral. Renders a fixture function with a known +header and asserts the section order, the `SYNOPSIS`→`USAGE` rename, indentation +survival, and exit 0. Then spot-checks two real, side-effect-free functions +(`poke`, `gip4`) end to end. Runs under `TERM=dumb`, where `set_color` emits +nothing, so assertions match plain text. + +**`test_every_user_facing_function_has_help`** — the guard required by +acceptance 5. For each function published by the header parser, assert its body +contains a help entry point (`__fish_help_header`, `_flag_help`, or a +`-h`/`--help` branch) *or* appears in an explicit exempt list. + +The exempt list lives in `tests/functional.fish` as one array with a one-line +comment per entry, seeded from §4.1 and §4.2. Rejected: a `# NOHELP` marker +comment in each exempt file — 39 file edits for no runtime benefit, and a marker +is the kind of thing that gets copy-pasted into a new function and silently +exempts it. A central list makes adding an exemption a reviewable act. + +The wiring check is deliberately static. Running all 40 functions with `--help` +would be a stronger test, but it executes `upgrade`, `sudo-toggle` and +`limine-edit` on the CI machine, and it is precisely wrong when the code is +broken — the case the test exists to catch. The renderer's behavior is proven +once against a fixture; the wiring is proven by inspection across all 40. + +--- + +## 7. Line delta + +| Change | Lines | +|---|---:| +| `functions/__fish_help_header.fish` (with license + header) | ~55 | +| 39 call sites × 1 line | +39 | +| `fish-deps` `case -h --help` | +2 | +| `tests/functional.fish` (2 tests + exempt list) | ~60 | +| Hand-rolled menus deleted | 0 | +| **Net** | **~+156** | + +The number that matters is not in the table: 40 menus not written. At the ~15 +lines the existing menus average, hand-writing them would have cost ~600 lines +of `echo` and a permanent second copy of the manual. + +--- + +## 8. Risks accepted + +- **First-argument-only interception** means `poke somefile --help` does not + print help. Correct for command runners, mildly surprising elsewhere. Accepted: + the alternative silently breaks `wake-lock rsync --help`. +- **Uncolored body text.** Generated menus will look plainer than the + hand-tuned ones. Accepted over a guessing heuristic. +- **A header missing `ARGUMENTS`** yields a menu without an arguments section. + `verify-manual.py` enforces only `SYNOPSIS`, `DESCRIPTION` and `EXAMPLE`. + Filling the gaps is authoring prose — out of scope. Note them, do not write them. +- **The seven `ls` variants** (`lD`, `lsr`, `lss`, `lstree`, `lt`, `ltr`, `lx`) + currently pass `--help` to `eza`, incidentally. After conversion they print + their own preset's documentation and `eza --help` reaches eza directly. This + is the largest judgment cluster in §4.3 and the most likely place for the + classification to be wrong. +- **`functions/CLAUDE.md` will still read as violated.** Its one-line rule names + only the wrapper exemption, not the prompt-hook class. A two-line amendment is + proposed separately; this spec does not change it. -- 2.54.0 From 8a2731d411f4a8c9957bdf168676b8feff9bea9c Mon Sep 17 00:00:00 2001 From: Rootiest Date: Mon, 7 Sep 2026 07:22:51 -0400 Subject: [PATCH 2/9] revert: untrack the spec AGENTS/ is gitignored upstream; a tracked file there pollutes git diff main..work, which is what the user reads when choosing which job to merge. Spec stays on disk, browsable, out of history. --- .../2026-09-07-header-driven-help-design.md | 446 ------------------ 1 file changed, 446 deletions(-) delete mode 100644 AGENTS/specs/2026-09-07-header-driven-help-design.md diff --git a/AGENTS/specs/2026-09-07-header-driven-help-design.md b/AGENTS/specs/2026-09-07-header-driven-help-design.md deleted file mode 100644 index dc259d0..0000000 --- a/AGENTS/specs/2026-09-07-header-driven-help-design.md +++ /dev/null @@ -1,446 +0,0 @@ -# Design: Header-Driven `--help` - -**Date:** 2026-09-07 -**Status:** Proposed -**Job:** 3 of the parallel improvement effort (fork of `08e66c8`, branch `work`) - ---- - -## Overview - -`functions/CLAUDE.md` requires every user-facing function to accept `-h`/`--help` -and print a formatted menu to stdout, "unless it strictly wraps another tool's -help." Most do not. - -The documentation those menus would contain already exists. Every documented -function carries a man-page-style comment header — `SYNOPSIS`, `DESCRIPTION`, -`ARGUMENTS`, `EXIT STATUS`, `RETURNS`, `EXAMPLE`, `NOTES` — which -`2026-07-26-function-headers-as-ssot-design.md` established as the SSOT for -Section 5 of the manual, and which `docs/verify-manual.py` already enforces. - -The header is written; it is simply not reachable at runtime. This spec makes it -reachable with one renderer function and one line per call site. It authors no -new documentation prose, and it changes nothing in the docs build pipeline. - -### Why not write the menus - -Writing a menu per function creates a second copy of facts the header already -holds, in a place no verifier checks. The 40 functions in scope would cost -roughly 600 lines of `echo` that immediately begin drifting from the header that -generates the manual. The 30 functions that already have hand-rolled menus are -the evidence: several of them already say things their headers do not. - ---- - -## Measured Baseline - -Taken 2026-09-07 against `functions/` at `08e66c8`. Counts come from -`manualtools.parse_functions()`, not from grep, so they match what the manual -actually publishes. - -| Fact | Value | -|---|---| -| `.fish` files in `functions/` | 185 | -| Files with a `# CATEGORY` comment | 110 | -| Functions the parser actually publishes | **109** | -| ...that print their own help menu today | **30** | -| ...that do not | **79** | -| Of those 79: exempt (see §4) | 39 | -| Of those 79: to convert | 40 | -| `fish tests/run-tests.fish` | 317/317 pass | -| `python3 docs/verify-manual.py` | 74/74 pass | - -The job brief quoted 110 / 34 / 78. Three corrections, all verified: - -- **110 → 109.** `zoxide.fish` carries two `# CATEGORY` blocks, but both resolve - to `_zoxide_*` names, which `parse_functions` drops as private. It publishes - nothing. -- **34 → 30.** The grep for `--help` counted `fisher.fish` and - `_fzf_configure_bindings_help.fish` (neither is a published function), and - `jr.fish` and `yt-dlp.fish`, which mention `--help` only inside their comment - headers and do not handle it in the body. -- **78 → 79.** `split` matched the grep because it uses `-h` for - `--horizontal`. It has no help menu, and its `-h` is spoken for. It belongs in - the target set as a special case (§4.3). - -### Pre-existing defects found, not fixed here - -- **`dops.fish` defines `function docker`, not `dops`.** The single header block - in that file resolves to the file stem, so the manual publishes an entry for - `dops` — a function that does not exist — while `docker` is undocumented. The - renderer resolves this correctly by accident (§3.2), but the manual entry - stays wrong. Out of scope: fixing it means authoring header content. -- **`sponge_filter_secrets.fish`** has a blank line between its header block and - its `function` line. The renderer tolerates it (§3.2); noted because it is the - only file that does. - ---- - -## 1. Requirements - -1. Every non-exempt published function responds to `-h` and `--help` with its own - documentation on **stdout**, exit 0. -2. Functions that pass `--help` through to a wrapped tool keep doing so. -3. `fish tests/run-tests.fish` stays at 317/317 plus the new checks. -4. `python3 docs/verify-manual.py` passes unchanged. -5. A test fails when a new user-facing function ships without help. -6. The docs build pipeline is not modified. -7. No header documentation content is added or rewritten. - ---- - -## 2. Renderer: `__fish_help_header` - -One new autoloaded function, `functions/__fish_help_header.fish`. - -```fish -__fish_help_header [args...] -``` - -It returns **1 — and prints nothing — only when `args[1]` is not a help flag.** -In every other case it prints something useful to stdout and returns 0. That -asymmetry is the safety-critical invariant: a return of 1 means "carry on with -the real work", so a parse failure must never produce it. `upgrade --help` -falling through to `paru -Syu --noconfirm` is the failure this rule exists to -prevent. - -### 2.1 Call site - -One line, as the **first statement of the function body**, above any opinionated -guard: - -```fish -function poke --description 'touch with automatic parent directory creation' - __fish_help_header (status current-function) $argv; and return 0 - ... -end -``` - -`status current-function` is evaluated in the caller's scope, so it yields the -caller's name. Placing the line above the C1/C4 guard means documentation stays -reachable when a component is disabled, and guarantees nothing side-effecting -runs before the flag is inspected. - -The `; and return 0` form leaves `$status` at 1 when no help flag was passed. -No function in scope reads `$status` as its first act, so this is inert — but -new call sites must not be added above a `$status` read. - -### 2.2 First-argument-only interception - -The renderer fires only when **`$argv[1]`** is `-h` or `--help`. Not anywhere in -`$argv`. - -This is not a simplification, it is a correctness requirement. `bkg`, -`wake-lock`, `split` and `spwin` take a command to run as their arguments. -Scanning all of `$argv` would make `wake-lock rsync --help` print `wake-lock`'s -help instead of running rsync. First-argument-only is also the pattern -`jobrunner` and `rand_string` already use. - -The 30 existing argparse-based menus keep accepting `--help` in any position. -This spec does not touch them (§5). - -### 2.3 Output format - -Matches `config-help`, the richest and most recent house menu: - -``` - bold - -USAGE bold brblue - two-space indent, deeper indent preserved - -DESCRIPTION - ... - -ARGUMENTS - ... -``` - -- `SYNOPSIS` renders as `USAGE`; `EXAMPLE` renders as `EXAMPLES`. Every other - label renders verbatim. -- `CATEGORY`, `COMPONENT` and `DEPENDENCIES` are build metadata and are skipped. -- `EXIT STATUS`, `RETURNS` and `NOTES` render when present. -- Blank lines inside a section are preserved (20 headers have multi-paragraph - `DESCRIPTION`s); trailing blanks are trimmed, mirroring - `manualtools._trailing_blanks`. -- **Body text is not colorized.** Only the name and the section headings carry - color. Guessing which words in a `DESCRIPTION` are flags or commands is a - heuristic with no upside; `config-help`'s hand-tuned coloring stays hand-tuned. - -### 2.4 Degraded output - -If `functions -D` yields no readable file, or the file holds no renderable -section, print the function name, its `--description`, and a pointer to -`help config ` — then return 0. Never return 1. - ---- - -## 3. Where the help text comes from - -### 3.1 Decision: parse the `.fish` source at call time - -Rejected alternatives: - -- **Read the generated `docs/fish-config.md`.** It is committed and - pre-formatted, but it is a *generated* artifact: a header edited without a - docs rebuild makes `--help` lie, which is the exact drift this job exists to - remove. It also costs a 163 KB scan and a `### ` heading match that can - collide with prose headings elsewhere in the manual. -- **Precompute a fish data file at build time**, mirroring the component - registry. This is the fastest option at runtime, but it adds a build artifact, - a new staleness class, and a step in the docs pipeline — which requirement 6 - forbids touching. -- **Reuse `config-help`.** It is a pager launcher over the whole manual; it - cannot emit one function's entry to stdout, and bending it into that shape - would cost more than the renderer. - -Source parsing wins because the source cannot be stale, the parse is ~15 lines -of `string match`, and the cost is one small file read on a keypress — never at -startup. - -### 3.2 Parsing rule - -Locate `function ` in the file, then walk **backwards** collecting the -contiguous run of `#` lines above it (skipping a blank separator line, for -`sponge_filter_secrets`). Within that run, apply `manualtools`' own grammar: - -- `^#\s+([A-Z][A-Z ]*[A-Z])\s*$` starts a section. -- Any other `#` line is body: strip `#`, then strip exactly three leading spaces - if present, so deeper indentation in nested `ARGUMENTS` tables survives. -- `#` lines before the first label (the copyright preamble) are ignored. - -Backward-walking from the `function` line replaces `manualtools._block_identity` -and is strictly more accurate at runtime: it resolves the three multi-header -files that publish a function (`fish-deps`, `gi`, `y`) correctly, and it gives -`docker` its real header in `dops.fish` where the Python parser attributes that -block to the file stem. - -### 3.3 Verified mechanics - -Proven in fish 4.9.1, not assumed. An autoloaded function calling -`(status current-function)` and `functions -D` on the result resolves to its own -defining file, both when called directly and when called from another function: - -``` -status current-function: probefn -functions -D self: .../functions/probefn.fish -status filename: .../functions/probefn.fish -``` - -Called *inside* the renderer, `status current-function` returns `__fish_help_header` -and `status stack-trace` returns the caller's path only as `~`-abbreviated prose. -Hence the name is passed as an argument rather than discovered. A working -prototype rendered `poke --help` in the format of §2.3 and left `poke`'s normal -path untouched. - ---- - -## 4. Classification of the 79 - -The exemption in `functions/CLAUDE.md` is "strictly wraps another tool's help." -Applied literally it splits into two classes, plus one collision case. - -**EXEMPT-A — shadow or pass-through.** The function shadows a real same-named -binary, or its entire body forwards `$argv` to one named tool. `ls --help` must -reach `ls`. Intercepting is a regression, and for the C1-guarded shadows it also -breaks the disabled-fallback contract, where the bare tool is supposed to answer. - -**EXEMPT-B — not a command.** Invoked by fish, never typed. Published in the -manual, but `--help` is meaningless. - -### 4.1 EXEMPT-A (35) - -| Function | Reason | -|---|---| -| `agy` | Shadows `agy`; body ends in `command agy $argv`. | -| `antigravity-ide` | Shadows `antigravity-ide`; only filters one stderr line. | -| `bash` | Shadows `bash`; adds `--rcfile`, forwards the rest. | -| `cat` | Shadows `cat`; C1 guard falls back to `command cat $argv`. | -| `cdi` | Whole body is `zi $argv`; zoxide owns the help. | -| `cffetch` | `clear` then `fastfetch $argv`. | -| `cheat` | `command cheat -c $argv`, else `tldr`/`man`. | -| `claude` | Shadows `claude`; body ends in `command claude $argv`. | -| `clone` | Whole body is `clone-in-kitty $argv`. | -| `clonet` | Whole body is `clone-in-kitty --type=tab $argv`. | -| `config-toggle` | Deprecated alias; forwards to `config-settings`, which has help. | -| `copy` | Whole body is `command cp $argv` with one directory special case. | -| `dops` | The file defines `docker`, which shadows `docker`. | -| `du` | Shadows `du`; unmatched args reach `command du`. | -| `dusize` | `du -sh $argv[1]` through the `du` shadow. | -| `fast-cli` | Whole body is `command fast $argv`. | -| `ffetch` | Whole body is `fastfetch $argv`. | -| `gitui` | `command gitui -t frappe.ron $argv`. | -| `gitup` | Its own `SYNOPSIS` is `gitup [args...]`, forwarded to `git fetch`. | -| `jr` | Whole body is `jobrunner $argv`; `jobrunner` has a help menu. | -| `joplin` | Whole body is the real `joplin` binary with `NODE_OPTIONS` set. | -| `less` | Shadows `less`; resolves a pager and forwards `$argv`. | -| `ls` | Shadows `ls`; forwards to `eza`/`lsd`/`command ls`. | -| `mkdir` | Shadows `mkdir`; any flag argument goes to `command mkdir -p`. | -| `mv` | Shadows `mv`; falls through to `command mv $argv`. | -| `paste` | Shadows coreutils `paste`; forwards `$argv` to `wl-paste`/`xclip`. | -| `ping` | Shadows `ping`; forwards to `prettyping` or `command ping`. | -| `rawfish` | Whole body is `env NO_TMUX=1 fish $argv`. | -| `rg` | Shadows `rg`; adds one flag under Kitty, forwards the rest. | -| `rm` | Shadows `rm`; unmatched args reach `command rm $argv`. | -| `search` | Whole body is `$aur $argv` (paru or yay). | -| `ssh` | Shadows `ssh`; forwards to `kitten ssh` or `command ssh`. | -| `top` | Shadows `top`; forwards to `btop` or `command top`. | -| `view` | Shadows vim's `view`; whole body is `nvim -R $argv`. | -| `yt-dlp` | Shadows `yt-dlp`; injects defaults, forwards `$argv` last. | - -### 4.2 EXEMPT-B (4) - -| Function | Reason | -|---|---| -| `fish_prompt` | Prompt hook; fish calls it, users never do. | -| `fish_right_prompt` | Prompt hook. | -| `fish_mode_prompt` | Prompt hook; body is empty. | -| `sponge_filter_secrets` | `sponge` plugin filter callback, invoked per command. | - -### 4.3 CONVERT (40) - -All take the standard call site of §2.1 unless noted. - -| Function | Reason it is not exempt | -|---|---| -| `bd-pull` | Own Gitea/Beads logic; `$argv[1]` is a repo slug. | -| `bkg` | Command runner; `$argv` is a command, not flags. First-arg rule applies. | -| `branch` | Own git logic; `branch --help` currently feeds `--help` to `git checkout -b`. | -| `check_fish_deps` | Ignores `$argv`; runs `fish-deps status`. | -| `claude-docs` | Ignores `$argv`; fires a fixed prompt. | -| `claude-pr` | Ignores `$argv`; fires a fixed prompt. | -| `cleanup` | Ignores `$argv`; `--help` currently runs `sudo pacman -Rns`. | -| `fast` | Ignores `$argv`; placeholder that prints a notice. | -| `fc` | Own history logic; `--help` currently becomes a history search term. | -| `fish-deps` | **Reuse:** wire `case -h --help` to the existing `__fish_deps_help`, return 0. Today `--help` hits `case '*'`, prints "Unknown subcommand", exits 1. | -| `fzf-update` | Ignores `$argv`; `--help` currently clones and installs fzf. | -| `gip` | Ignores `$argv`. | -| `gip4` | Ignores `$argv`. | -| `gip6` | Ignores `$argv`. | -| `hist` | Ignores `$argv`; opens an fzf picker. | -| `lD` | Not a binary; three-branch body whose value is the `--only-dirs` preset. | -| `ld` | Ignores `$argv`; launches lazydocker with a computed `DOCKER_HOST`. | -| `limine-edit` | Ignores `$argv`; `--help` currently runs `sudoedit` and re-enrolls Limine. | -| `lock` | Ignores `$argv`; `lock --help` currently locks the screen. | -| `lsr` | Not a binary; value is the reverse-time preset. | -| `lss` | Not a binary; value is the size-sort preset. | -| `lstree` | Not a binary; value is the recursive-tree preset. | -| `lt` | Not a binary; value is the depth-2 tree preset. | -| `ltr` | Not a binary; value is the reverse-time preset. | -| `lx` | Not a binary; value is the extension-sort preset. | -| `parur` | Ignores `$argv`; opens an fzf package picker. | -| `poke` | Own logic; `--help` would be created as a file. | -| `ports` | Ignores `$argv`; runs `sudo lsof`. | -| `qr` | `--help` would be encoded into a QR code. | -| `sbver` | Own `--brief` flag; runs `sudo sbctl verify`. | -| `screensleep` | Ignores `$argv`; `--help` currently blanks the display. | -| `split` | **`--help` only, no `-h`.** Its own `ARGUMENTS` documents `-h, --horizontal`. | -| `spwin` | Own terminal dispatch; first-arg rule protects `spwin --help`. | -| `steam-dl` | Ignores `$argv`. | -| `sudo-toggle` | Ignores `$argv`; `--help` currently rewrites `/etc/sudoers.d`. | -| `swapstat` | Ignores `$argv`. | -| `tab` | Own terminal dispatch; first-arg rule protects the forwarded command. | -| `tmux-clean` | Ignores `$argv`; `--help` currently kills tmux sessions. | -| `upgrade` | Ignores `$argv`; `--help` currently runs `paru -Syu --noconfirm`. | -| `wake-lock` | Command runner; first-arg rule protects `wake-lock --help`. | - -Eight of these currently perform a destructive or irreversible action when handed -`--help`, because they ignore `$argv` entirely and just run: `cleanup` -(`sudo pacman -Rns`), `fzf-update` (clone and install), `limine-edit` (`sudoedit` -and re-enroll), `lock` (locks the session), `screensleep` (blanks the display), -`sudo-toggle` (rewrites `/etc/sudoers.d`), `tmux-clean` (kills tmux sessions) and -`upgrade` (`paru -Syu --noconfirm`). For these, converting is a safety fix before -it is a documentation one. - -`split` is the only `-h` collision in the whole set; every other CONVERT function -was checked and uses `-h` for nothing. - ---- - -## 5. The 30 existing menus stay - -Not converted in this job. The risk is asymmetric: - -- Three of them carry content that **does not exist in any header**, so - conversion would delete documentation: `config-help`'s ov navigation keys and - pager fallback chain, `qc`'s appended `aichat --help`, `superpowers`' inline - usage block. Recovering them means authoring header prose, which requirement 7 - forbids. -- Thirteen use `argparse`/`_flag_help` and accept `--help` in any position. - Moving them to the first-argument-only rule of §2.2 is a real behavior change - for menus people already use. -- No acceptance criterion requires it. The duplication they represent is - pre-existing and static — it does not grow as new functions land, because new - functions will use the renderer. - -Thirty conversions is thirty independent chances to change output somebody -relies on, bought for no test. Recommended as a separate follow-up branch, where -each diff can be reviewed against its own before/after output. - ---- - -## 6. Testing - -Two additions to `tests/functional.fish`, which runs inside the sandboxed loaded -session where `functions -D` resolves to the sandbox copy. - -**`test_help_renderer`** — behavioral. Renders a fixture function with a known -header and asserts the section order, the `SYNOPSIS`→`USAGE` rename, indentation -survival, and exit 0. Then spot-checks two real, side-effect-free functions -(`poke`, `gip4`) end to end. Runs under `TERM=dumb`, where `set_color` emits -nothing, so assertions match plain text. - -**`test_every_user_facing_function_has_help`** — the guard required by -acceptance 5. For each function published by the header parser, assert its body -contains a help entry point (`__fish_help_header`, `_flag_help`, or a -`-h`/`--help` branch) *or* appears in an explicit exempt list. - -The exempt list lives in `tests/functional.fish` as one array with a one-line -comment per entry, seeded from §4.1 and §4.2. Rejected: a `# NOHELP` marker -comment in each exempt file — 39 file edits for no runtime benefit, and a marker -is the kind of thing that gets copy-pasted into a new function and silently -exempts it. A central list makes adding an exemption a reviewable act. - -The wiring check is deliberately static. Running all 40 functions with `--help` -would be a stronger test, but it executes `upgrade`, `sudo-toggle` and -`limine-edit` on the CI machine, and it is precisely wrong when the code is -broken — the case the test exists to catch. The renderer's behavior is proven -once against a fixture; the wiring is proven by inspection across all 40. - ---- - -## 7. Line delta - -| Change | Lines | -|---|---:| -| `functions/__fish_help_header.fish` (with license + header) | ~55 | -| 39 call sites × 1 line | +39 | -| `fish-deps` `case -h --help` | +2 | -| `tests/functional.fish` (2 tests + exempt list) | ~60 | -| Hand-rolled menus deleted | 0 | -| **Net** | **~+156** | - -The number that matters is not in the table: 40 menus not written. At the ~15 -lines the existing menus average, hand-writing them would have cost ~600 lines -of `echo` and a permanent second copy of the manual. - ---- - -## 8. Risks accepted - -- **First-argument-only interception** means `poke somefile --help` does not - print help. Correct for command runners, mildly surprising elsewhere. Accepted: - the alternative silently breaks `wake-lock rsync --help`. -- **Uncolored body text.** Generated menus will look plainer than the - hand-tuned ones. Accepted over a guessing heuristic. -- **A header missing `ARGUMENTS`** yields a menu without an arguments section. - `verify-manual.py` enforces only `SYNOPSIS`, `DESCRIPTION` and `EXAMPLE`. - Filling the gaps is authoring prose — out of scope. Note them, do not write them. -- **The seven `ls` variants** (`lD`, `lsr`, `lss`, `lstree`, `lt`, `ltr`, `lx`) - currently pass `--help` to `eza`, incidentally. After conversion they print - their own preset's documentation and `eza --help` reaches eza directly. This - is the largest judgment cluster in §4.3 and the most likely place for the - classification to be wrong. -- **`functions/CLAUDE.md` will still read as violated.** Its one-line rule names - only the wrapper exemption, not the prompt-hook class. A two-line amendment is - proposed separately; this spec does not change it. -- 2.54.0 From 9077d9837e9fed4c29cea84998c8d965e133d3f2 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Mon, 7 Sep 2026 14:59:05 -0400 Subject: [PATCH 3/9] feat(help): add __fish_help_header runtime renderer Parses a function's own man-page comment header at call time and prints it as a help menu on stdout, so the documentation that already generates Section 5 of the manual becomes reachable from the shell. Reads the .fish source rather than the generated docs/fish-config.md, so it cannot go stale between a header edit and a docs rebuild. Walks backwards from the `function` line to collect the header, which resolves multi-header files (fish-deps, gi, y) without reimplementing manualtools._block_identity. Returns 1 only when argv[1] is not a help flag; every other path prints and returns 0. A return of 1 hands control back to the caller's body. Nothing calls it yet. --- functions/__fish_help_header.fish | 141 ++++++++++++++++++++++++++++++ tests/functional.fish | 85 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 functions/__fish_help_header.fish diff --git a/functions/__fish_help_header.fish b/functions/__fish_help_header.fish new file mode 100644 index 0000000..68920be --- /dev/null +++ b/functions/__fish_help_header.fish @@ -0,0 +1,141 @@ +# Copyright (C) 2026 Rootiest +# SPDX-License-Identifier: AGPL-3.0-or-later + +# SYNOPSIS +# __fish_help_header [args...] +# +# DESCRIPTION +# Prints 's man-page comment header as a help menu on stdout. +# Intended as the first statement of a user-facing function's body: +# +# __fish_help_header (status current-function) $argv; and return 0 +# +# Returns 1 -- printing nothing -- ONLY when args[1] is not a help flag. +# Every other outcome, including an unreadable or headerless source +# file, prints something and returns 0. That asymmetry is load-bearing: +# a return of 1 means "run the real body", and the real body of upgrade +# is `paru -Syu --noconfirm`. A parse failure must never return 1. +# +# Only args[1] is inspected, never the whole list. wake-lock, bkg, +# split and spwin take a command to run as their arguments, so +# scanning all of $argv would make `wake-lock rsync --help` print +# wake-lock's own help instead of running rsync. +# +# The header is read from the caller's source at call time rather than +# from the generated manual, so it cannot go stale between a header +# edit and a docs rebuild. +# +# ARGUMENTS +# name The calling function's name, from (status current-function) +# args... The caller's $argv, forwarded verbatim +# +# EXIT STATUS +# 0 Help was printed, including the degraded fallback +# 1 args[1] is not -h/--help; the caller should carry on +# +# EXAMPLE +# __fish_help_header (status current-function) $argv; and return 0 +# +# NOTES +# Section labels are those of the manual SSOT parser in +# docs/manualtools.py. CATEGORY, COMPONENT and DEPENDENCIES are build +# metadata and are suppressed; SYNOPSIS renders as USAGE and EXAMPLE as +# EXAMPLES. +function __fish_help_header --argument-names name + # First argument only -- see DESCRIPTION. + contains -- "$argv[2]" -h --help; or return 1 + + set -l c_ttl (set_color --bold) + set -l c_sec (set_color --bold brblue) + set -l c_rst (set_color normal) + set -l miss " No documentation header found. Try: help config $name" + + set -l file (functions -D -- $name 2>/dev/null) + if not test -f "$file" + # Quoted: set_color yields an EMPTY LIST under TERM=dumb, and an + # unquoted empty list in a concatenation annihilates the whole + # word -- the title line would silently vanish wherever colour is + # off, which is exactly where a test would be reading it. + echo "$c_ttl$name$c_rst" + echo $miss + return 0 + end + + # Collect the contiguous comment run directly above `function `, + # walking backwards. This resolves multi-header files (fish-deps, gi, + # y) without reimplementing manualtools._block_identity, and is more + # accurate at runtime: in dops.fish it finds the header above + # `function docker` rather than attributing it to the file stem. + # One blank separator line is tolerated -- sponge_filter_secrets.fish + # is the only file that has one, and JOB-BRIEF-FINDINGS.md records it + # so this skip is not mistaken for dead code. + set -l lines (string split \n -- (command cat $file)) + set -l pat '^\s*function\s+'(string escape --style=regex -- $name)'(\s|$)' + set -l start 0 + for i in (seq (count $lines)) + if string match -qr -- $pat $lines[$i] + set start $i + break + end + end + + set -l header + if test $start -gt 1 + set -l j (math $start - 1) + if test -z (string trim -- "$lines[$j]") + set j (math $j - 1) + end + while test $j -ge 1; and string match -q '#*' -- $lines[$j] + set -p header $lines[$j] + set j (math $j - 1) + end + end + + # Render. Comment lines before the first `# LABEL` -- the copyright + # preamble -- carry no label and are dropped, matching + # manualtools._header_blocks. + set -l skip CATEGORY COMPONENT DEPENDENCIES + set -l label "" + set -l out + for line in $header + set -l m (string match -r -- '^#\s+([A-Z][A-Z ]*[A-Z])\s*$' $line) + if set -q m[2] + set label $m[2] + contains -- $label $skip; and continue + set -l shown (string replace SYNOPSIS USAGE -- $label) + set shown (string replace EXAMPLE EXAMPLES -- $shown) + # One blank line before a heading, never two: the header's own + # `#` separator has usually already emitted one. + if set -q out[1]; and test -n (string trim -- "$out[-1]") + set -a out "" + end + set -a out "$c_sec$shown$c_rst" + continue + end + test -n "$label"; or continue + contains -- $label $skip; and continue + set -l body (string sub -s 2 -- $line) + if string match -q ' *' -- $body + set -a out " "(string sub -s 4 -- $body) + else + set -a out (string trim -- $body) + end + end + + # Trim the trailing blank separator, mirroring + # manualtools._trailing_blanks. + while set -q out[-1]; and test -z (string trim -- "$out[-1]") + set -e out[-1] + end + + echo "$c_ttl$name$c_rst" + if test (count $out) -eq 0 + echo $miss + else + # out[1] is always a heading -- a body line cannot precede the + # first label -- so this blank is never doubled. + echo "" + printf '%s\n' $out + end + return 0 +end diff --git a/tests/functional.fish b/tests/functional.fish index e4d1651..4641cc1 100644 --- a/tests/functional.fish +++ b/tests/functional.fish @@ -97,6 +97,91 @@ function test_vault_dir_honors_override test "$got" = /tmp/vault-override-check end +# ── Header-driven --help ───────────────────────────────────────────── +# Helper: run ` $argv` in a throwaway fish that can see both $dir and +# the loaded session's function path, so a fixture function can call the +# real __fish_help_header. Paths here are mktemp -d output, never spaced. +function _help_probe --argument-names dir + env TERM=dumb fish --no-config -c \ + "set -g fish_function_path $dir $fish_function_path; $argv[2..]" +end + +function test_help_renderer + set -l tmp (mktemp -d) + printf '%s\n' \ + '# Copyright (C) 2026 Rootiest' \ + '' \ + '# CATEGORY' \ + '# 99-fixture' \ + '#' \ + '# SYNOPSIS' \ + '# fixturefn [options]' \ + '#' \ + '# DESCRIPTION' \ + '# First paragraph.' \ + '#' \ + '# Second paragraph.' \ + '#' \ + '# ARGUMENTS' \ + '# -x Do the thing' \ + '# more Indented continuation' \ + '#' \ + '# EXAMPLE' \ + '# fixturefn -x' \ + 'function fixturefn' \ + ' __fish_help_header (status current-function) $argv; and return 0' \ + ' echo RAN-BODY' \ + 'end' >$tmp/fixturefn.fish + + set -l out (_help_probe $tmp 'fixturefn --help') + set -l code $status + set -l text (string join \n $out) + rm -rf $tmp + + set -l failed 0 + if test $code -ne 0 + echo " renderer exited $code, expected 0" + set failed 1 + end + if contains -- RAN-BODY $out + echo " body executed despite --help" + set failed 1 + end + if not contains -- USAGE $out + echo " missing USAGE heading (SYNOPSIS should render as USAGE)" + set failed 1 + end + if contains -- CATEGORY $out + echo " CATEGORY leaked into the menu" + set failed 1 + end + if not string match -q '* more Indented continuation*' -- $text + echo " nested ARGUMENTS indentation lost" + set failed 1 + end + # Index-based, not a glob: fish's `string match` glob `*` does not + # span newlines, so a pattern straddling two lines silently never + # matches and the assertion would pass for the wrong reason. + set -l i (contains -i -- " First paragraph." $out) + if test -z "$i" + echo " DESCRIPTION body missing entirely" + set failed 1 + else + # Indices hoisted: a command substitution inside a quoted index + # ("$out[(math ...)]") is a fish parse error, not an expansion. + set -l gap (math $i + 1) + set -l nxt (math $i + 2) + if test -n "$out[$gap]" + echo " multi-paragraph DESCRIPTION lost its blank line" + set failed 1 + else if test "$out[$nxt]" != " Second paragraph." + echo " second paragraph missing after the blank" + set failed 1 + end + end + test $failed -eq 0 +end + function functional_test_main set -l names (functions -a | string match 'test_*' | sort) set -l failed 0 -- 2.54.0 From b424b26700194ce98cc71207fc7f714ada152b9f Mon Sep 17 00:00:00 2001 From: Rootiest Date: Mon, 7 Sep 2026 19:59:50 -0400 Subject: [PATCH 4/9] test(help): pin the renderer's degraded-path contract A missing or label-less header must still print and exit 0. Returning 1 would hand control back to the caller's body, which for upgrade(1) is a full system upgrade. Both fixtures reach the renderer's bottom `count $out -eq 0` fallback, not the unreadable-source branch. Mutating that fallback to `return 1` turns the test red with: headerless executed its body despite --help malformed executed its body despite --help The mutation was reverted before this commit. --- tests/functional.fish | 55 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/functional.fish b/tests/functional.fish index 4641cc1..2f356e4 100644 --- a/tests/functional.fish +++ b/tests/functional.fish @@ -182,6 +182,61 @@ function test_help_renderer test $failed -eq 0 end +function test_help_renderer_degrades_safely + # The renderer must return 1 ONLY when argv[1] is not a help flag. + # A missing or label-less header must still print and exit 0, because + # returning 1 hands control back to the caller's body -- and the body + # of upgrade(1) is `paru -Syu --noconfirm`. + set -l tmp (mktemp -d) + printf '%s\n' \ + 'function headerless' \ + ' __fish_help_header (status current-function) $argv; and return 0' \ + " touch $tmp/BODY-RAN" \ + 'end' >$tmp/headerless.fish + # A comment run carrying no `# LABEL` line at all. + printf '%s\n' \ + '# just an ordinary comment, no labels here' \ + 'function malformed' \ + ' __fish_help_header (status current-function) $argv; and return 0' \ + " touch $tmp/BODY-RAN" \ + 'end' >$tmp/malformed.fish + + set -l failed 0 + for fn in headerless malformed + set -l out (_help_probe $tmp "$fn --help") + set -l code $status + if test $code -ne 0 + echo " $fn --help exited $code, expected 0" + set failed 1 + end + if test (count $out) -eq 0 + echo " $fn --help printed nothing" + set failed 1 + end + if not contains -- $fn $out + echo " $fn --help did not name the function" + set failed 1 + end + if test -e $tmp/BODY-RAN + echo " $fn executed its body despite --help" + set failed 1 + rm -f $tmp/BODY-RAN + end + end + + # The inverse: no help flag must return 1 and let the body run. + _help_probe $tmp headerless >/dev/null 2>&1 + if not test -e $tmp/BODY-RAN + echo " body did NOT run when no help flag was passed" + set failed 1 + end + + rm -rf $tmp + # Explicit, never a trailing `if`: standing gotcha #5 -- an if with no + # branch taken resolves $status to 0 and the test would pass silently. + test $failed -eq 0 +end + function functional_test_main set -l names (functions -a | string match 'test_*' | sort) set -l failed 0 -- 2.54.0 From 037588ecf6092a2bbedb3c1dc26a2f1c0f2d1088 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Mon, 7 Sep 2026 20:04:02 -0400 Subject: [PATCH 5/9] fix(help): stop eight functions executing on --help These eight ignore $argv entirely, so passing --help ran the command instead of describing it. The new check shadows every external binary they reach with a recording stub on PATH and fails if anything is invoked. Before the fix it reported, verbatim: cleanup --help EXECUTED: pacman -Qtdq fzf-update --help EXECUTED: git clone --depth 1 https://github.com/junegunn/fzf.git /tmp/.../.fzf limine-edit --help EXECUTED: sudoedit /boot/limine.conf sudo limine-enroll-config sudo limine-mkinitcpio sudo sbctl sign-all lock --help EXECUTED: loginctl lock-session screensleep --help EXECUTED: busctl --user call org.kde.kglobalaccel ... invokeShortcut s Turn Off Screen sudo-toggle --help EXECUTED: sudo stat -c %s /etc/sudoers.d/nofail-toggle sudo tee /etc/sudoers.d/nofail-toggle tmux-clean --help EXECUTED: tmux list-sessions -F #{session_name} #{session_attached} upgrade --help EXECUTED: paru -Syu --noconfirm cleanup's log line is the read that precedes `sudo pacman -Rns $orphans`, which the stub suppressed by returning no orphans; on a real machine with orphans present the removal ran. Each now answers --help from its own comment header. The call site is the first statement of the body, above the C4 guard, so help stays reachable when the component is disabled and nothing side-effecting runs first. --- functions/cleanup.fish | 2 ++ functions/fzf-update.fish | 2 ++ functions/limine-edit.fish | 2 ++ functions/lock.fish | 2 ++ functions/screensleep.fish | 2 ++ functions/sudo-toggle.fish | 2 ++ functions/tmux-clean.fish | 2 ++ functions/upgrade.fish | 2 ++ tests/functional.fish | 52 ++++++++++++++++++++++++++++++++++++++ 9 files changed, 68 insertions(+) diff --git a/functions/cleanup.fish b/functions/cleanup.fish index eaa30db..b908ee7 100644 --- a/functions/cleanup.fish +++ b/functions/cleanup.fish @@ -14,6 +14,8 @@ # EXAMPLE # cleanup function cleanup --description 'Log orphans to ~/.removed_orphans and remove them' + __fish_help_header (status current-function) $argv; and return 0 + set -l orphans (pacman -Qtdq) if test -n "$orphans" echo "📝 Logging orphans to ~/.removed_orphans..." diff --git a/functions/fzf-update.fish b/functions/fzf-update.fish index 1a403ed..5297dc8 100644 --- a/functions/fzf-update.fish +++ b/functions/fzf-update.fish @@ -14,6 +14,8 @@ # EXAMPLE # fzf-update function fzf-update --description 'Install or upgrade fzf from git HEAD' + __fish_help_header (status current-function) $argv; and return 0 + if test -d ~/.fzf echo "Updating fzf..." git -C ~/.fzf pull --ff-only diff --git a/functions/limine-edit.fish b/functions/limine-edit.fish index f22a652..9f3b502 100644 --- a/functions/limine-edit.fish +++ b/functions/limine-edit.fish @@ -16,6 +16,8 @@ # EXAMPLE # limine-edit function limine-edit --description 'Safely edit and re-verify Limine configuration' + __fish_help_header (status current-function) $argv; and return 0 + # 1. Open the config with sudoedit sudoedit /boot/limine.conf diff --git a/functions/lock.fish b/functions/lock.fish index c1e61bc..302d29d 100644 --- a/functions/lock.fish +++ b/functions/lock.fish @@ -13,5 +13,7 @@ # EXAMPLE # lock function lock --wraps='loginctl' --description 'alias lock=loginctl' + __fish_help_header (status current-function) $argv; and return 0 + loginctl lock-session end diff --git a/functions/screensleep.fish b/functions/screensleep.fish index 452ff9e..0bb4442 100644 --- a/functions/screensleep.fish +++ b/functions/screensleep.fish @@ -14,6 +14,8 @@ # EXAMPLE # screensleep function screensleep --description 'Turn off the display using KDE PowerDevil' + __fish_help_header (status current-function) $argv; and return 0 + # Optional: 1-second delay to ensure no keystrokes wake it immediately sleep 1 busctl --user call \ diff --git a/functions/sudo-toggle.fish b/functions/sudo-toggle.fish index e46547f..c218e98 100644 --- a/functions/sudo-toggle.fish +++ b/functions/sudo-toggle.fish @@ -19,6 +19,8 @@ # EXAMPLE # sudo-toggle function sudo-toggle --description 'Toggle sudo password requirement on/off' + __fish_help_header (status current-function) $argv; and return 0 + # Check the file size using sudo stat to see if our bypass rule is active set -l file_size (sudo stat -c %s /etc/sudoers.d/nofail-toggle 2>/dev/null) diff --git a/functions/tmux-clean.fish b/functions/tmux-clean.fish index cf775c4..c31765e 100644 --- a/functions/tmux-clean.fish +++ b/functions/tmux-clean.fish @@ -14,6 +14,8 @@ # EXAMPLE # tmux-clean function tmux-clean --description 'Kill all tmux sessions except the current one' + __fish_help_header (status current-function) $argv; and return 0 + # Get a list of all session names that are NOT currently attached set sessions (tmux list-sessions -F '#{session_name} #{session_attached}' | string match -rv ' 1$' | string split -f1 ' ') diff --git a/functions/upgrade.fish b/functions/upgrade.fish index 68702bb..20d2fc9 100644 --- a/functions/upgrade.fish +++ b/functions/upgrade.fish @@ -21,6 +21,8 @@ # EXAMPLE # upgrade function upgrade --description 'Full system upgrade via paru or yay' + __fish_help_header (status current-function) $argv; and return 0 + # Opinionated guard (C4): integrations disabled if not __fish_config_op_enabled (status current-function) set -l c_err (set_color red) diff --git a/tests/functional.fish b/tests/functional.fish index 2f356e4..f027194 100644 --- a/tests/functional.fish +++ b/tests/functional.fish @@ -237,6 +237,58 @@ function test_help_renderer_degrades_safely test $failed -eq 0 end +function test_help_never_executes_destructive_path + # These eight ignore $argv entirely, so before the header-driven help + # landed, `upgrade --help` ran `paru -Syu --noconfirm`. The check has + # to prove --help does NOT reach the destructive path *without* ever + # running it: every external binary the eight can reach is shadowed by + # a recording stub on PATH, and the recorder must stay empty. + # + # WARNING: a silent pass here means a MISSING STUB, not success. If a + # function shows neither an EXECUTED line nor its own help, its + # command is absent from the stub list below -- add it. A test that + # cannot fail proves nothing about a body that runs sudo pacman -Rns. + set -l root (realpath (dirname (status filename))/..) + set -l tmp (mktemp -d) + mkdir -p $tmp/bin + set -l log $tmp/invoked.log + touch $log + + for b in sudo pacman paru yay loginctl busctl tmux systemd-inhibit \ + sudoedit limine-enroll-config limine-mkinitcpio sbctl git fzf steam + printf '#!/bin/sh\necho "$(basename "$0") $*" >> %s\n' $log >$tmp/bin/$b + chmod +x $tmp/bin/$b + end + + set -l failed 0 + for fn in cleanup fzf-update limine-edit lock screensleep sudo-toggle \ + tmux-clean upgrade + set -l out (env TERM=dumb PATH="$tmp/bin:$PATH" HOME=$tmp \ + fish --no-config -c \ + "set -g fish_function_path $root/functions $fish_function_path + $fn --help" 2>/dev/null) + set -l code $status + + if test $code -ne 0 + echo " $fn --help exited $code, expected 0" + set failed 1 + end + if not contains -- $fn $out + echo " $fn --help did not print its own help" + set failed 1 + end + set -l ran (string trim -- (command cat $log)) + if test -n "$ran" + echo " $fn --help EXECUTED: $ran" + set failed 1 + end + echo -n "" >$log + end + + rm -rf $tmp + test $failed -eq 0 +end + function functional_test_main set -l names (functions -a | string match 'test_*' | sort) set -l failed 0 -- 2.54.0 From 71574b50304bb921380e542a6914da46b68cf59b Mon Sep 17 00:00:00 2001 From: Rootiest Date: Mon, 7 Sep 2026 20:05:15 -0400 Subject: [PATCH 6/9] feat(help): header-driven --help for listing presets lD, lsr, lss, lstree, lt, ltr, lx and ld. None shadows a real binary, so there is no disabled-fallback contract to honour, and `eza --help` documented eza rather than the preset that is these functions' entire content. First-arg-only interception leaves `lt -la --help` passing through to eza untouched. --- functions/lD.fish | 2 ++ functions/ld.fish | 2 ++ functions/lsr.fish | 2 ++ functions/lss.fish | 2 ++ functions/lstree.fish | 2 ++ functions/lt.fish | 2 ++ functions/ltr.fish | 2 ++ functions/lx.fish | 2 ++ 8 files changed, 16 insertions(+) diff --git a/functions/lD.fish b/functions/lD.fish index 10ee313..9849199 100644 --- a/functions/lD.fish +++ b/functions/lD.fish @@ -17,6 +17,8 @@ # EXAMPLE # lD ~/projects function lD --description 'List directories only' + __fish_help_header (status current-function) $argv; and return 0 + if which eza >/dev/null 2>&1 eza --only-dirs --long --icons --color=auto --hyperlink $argv else if which lsd >/dev/null 2>&1 diff --git a/functions/ld.fish b/functions/ld.fish index 5bc6b4a..4baa58e 100644 --- a/functions/ld.fish +++ b/functions/ld.fish @@ -17,6 +17,8 @@ # EXAMPLE # ld function ld --description 'Run lazydocker on the current Docker context' + __fish_help_header (status current-function) $argv; and return 0 + if not type -q docker echo "ld: docker is not installed" >&2 return 1 diff --git a/functions/lsr.fish b/functions/lsr.fish index 344530d..d680556 100644 --- a/functions/lsr.fish +++ b/functions/lsr.fish @@ -17,6 +17,8 @@ # EXAMPLE # lsr ~/projects function lsr --description 'Reversed time-sorted listing' + __fish_help_header (status current-function) $argv; and return 0 + if which eza >/dev/null 2>&1 eza --oneline --sort=modified --reverse --icons --color=auto --hyperlink $argv else if which lsd >/dev/null 2>&1 diff --git a/functions/lss.fish b/functions/lss.fish index 9973905..d0d09a8 100644 --- a/functions/lss.fish +++ b/functions/lss.fish @@ -17,6 +17,8 @@ # EXAMPLE # lss ~/downloads function lss --description 'Size-sorted listing' + __fish_help_header (status current-function) $argv; and return 0 + if which eza >/dev/null 2>&1 eza --oneline --long --all --sort=size --icons --color=auto --hyperlink --color-scale=size --color-scale-mode=gradient $argv else if which lsd >/dev/null 2>&1 diff --git a/functions/lstree.fish b/functions/lstree.fish index 49993ea..7c1be9f 100644 --- a/functions/lstree.fish +++ b/functions/lstree.fish @@ -17,6 +17,8 @@ # EXAMPLE # lstree ~/projects/myapp function lstree --description 'Full recursive tree listing' + __fish_help_header (status current-function) $argv; and return 0 + if which eza >/dev/null 2>&1 eza --tree --icons --color=auto --hyperlink=auto $argv else if which lsd >/dev/null 2>&1 diff --git a/functions/lt.fish b/functions/lt.fish index 6e0fcfe..b16a526 100644 --- a/functions/lt.fish +++ b/functions/lt.fish @@ -17,6 +17,8 @@ # EXAMPLE # lt ~/projects function lt --description 'Tree listing, depth 2' + __fish_help_header (status current-function) $argv; and return 0 + if which eza >/dev/null 2>&1 eza --tree --level=2 --icons --color=auto --hyperlink $argv else if which lsd >/dev/null 2>&1 diff --git a/functions/ltr.fish b/functions/ltr.fish index 11e80ab..35d6910 100644 --- a/functions/ltr.fish +++ b/functions/ltr.fish @@ -18,6 +18,8 @@ # EXAMPLE # ltr ~/projects function ltr --description 'Reversed time-sorted listing' + __fish_help_header (status current-function) $argv; and return 0 + if which eza >/dev/null 2>&1 eza --long --all --sort=modified --icons --hyperlink --color=auto --color-scale=age --color-scale-mode=gradient $argv else if which lsd >/dev/null 2>&1 diff --git a/functions/lx.fish b/functions/lx.fish index 663be39..f467d11 100644 --- a/functions/lx.fish +++ b/functions/lx.fish @@ -17,6 +17,8 @@ # EXAMPLE # lx ~/projects function lx --description 'Extension-sorted listing' + __fish_help_header (status current-function) $argv; and return 0 + if which eza >/dev/null 2>&1 eza --long --all --sort=extension --icons --color=auto --hyperlink $argv else if which lsd >/dev/null 2>&1 -- 2.54.0 From 3399149cbae4757f5b234f7b4fe00183b909923c Mon Sep 17 00:00:00 2001 From: Rootiest Date: Mon, 7 Sep 2026 20:06:23 -0400 Subject: [PATCH 7/9] feat(help): header-driven --help for system-info functions gip, gip4, gip6, ports, swapstat, sbver and steam-dl. All ignored $argv, so --help previously ran the query or the launcher. sbver keeps its own --brief flag: only argv[1] is inspected and --brief is not a help flag, so the body still runs for it. --- functions/gip.fish | 2 ++ functions/gip4.fish | 2 ++ functions/gip6.fish | 2 ++ functions/ports.fish | 2 ++ functions/sbver.fish | 2 ++ functions/steam-dl.fish | 2 ++ functions/swapstat.fish | 2 ++ 7 files changed, 14 insertions(+) diff --git a/functions/gip.fish b/functions/gip.fish index 19cbd91..cc73b11 100644 --- a/functions/gip.fish +++ b/functions/gip.fish @@ -14,6 +14,8 @@ # EXAMPLE # gip function gip --description 'Show all public IP addresses' + __fish_help_header (status current-function) $argv; and return 0 + echo -n "IPv4: " curl -4 -s --max-time 2 https://icanhazip.com || echo "Not detected" echo -n "IPv6: " diff --git a/functions/gip4.fish b/functions/gip4.fish index 9a71d1f..5431be4 100644 --- a/functions/gip4.fish +++ b/functions/gip4.fish @@ -13,5 +13,7 @@ # EXAMPLE # gip4 function gip4 --wraps='curl' --description 'Get public IPv4 address' + __fish_help_header (status current-function) $argv; and return 0 + curl -4 -s https://icanhazip.com end diff --git a/functions/gip6.fish b/functions/gip6.fish index 735ffc6..52ca05c 100644 --- a/functions/gip6.fish +++ b/functions/gip6.fish @@ -21,6 +21,8 @@ # EXAMPLE # gip6 function gip6 --description 'Get public IPv6 address' + __fish_help_header (status current-function) $argv; and return 0 + # Use -6 to force IPv6 and --fail to catch network errors set -l ip (curl -6 -s --fail https://icanhazip.com 2>/dev/null) diff --git a/functions/ports.fish b/functions/ports.fish index 19c0e57..6836c2c 100644 --- a/functions/ports.fish +++ b/functions/ports.fish @@ -14,5 +14,7 @@ # EXAMPLE # ports function ports --wraps='sudo' --description 'Show active network listeners' + __fish_help_header (status current-function) $argv; and return 0 + sudo lsof -iTCP -sTCP:LISTEN -P -n end diff --git a/functions/sbver.fish b/functions/sbver.fish index b9a807d..87d7965 100644 --- a/functions/sbver.fish +++ b/functions/sbver.fish @@ -24,6 +24,8 @@ # sbver # sbver --brief function sbver --description 'Verifies Secure Boot status of EFI binaries using sbctl' + __fish_help_header (status current-function) $argv; and return 0 + if not type -q sbctl echo "Error: 'sbctl' is not installed." return 1 diff --git a/functions/steam-dl.fish b/functions/steam-dl.fish index 3c4395d..7c7ab1c 100644 --- a/functions/steam-dl.fish +++ b/functions/steam-dl.fish @@ -14,6 +14,8 @@ # EXAMPLE # steam-dl function steam-dl --description 'Run Steam while inhibiting system sleep' + __fish_help_header (status current-function) $argv; and return 0 + echo "Inhibiting sleep while Steam downloads..." systemd-inhibit --why="Active Download" --who="User" --what=idle:sleep steam end diff --git a/functions/swapstat.fish b/functions/swapstat.fish index 55a26fe..f379d4c 100644 --- a/functions/swapstat.fish +++ b/functions/swapstat.fish @@ -15,6 +15,8 @@ # EXAMPLE # swapstat function swapstat --description 'View colorized zRAM and swappiness status' + __fish_help_header (status current-function) $argv; and return 0 + set -l swappiness (sysctl -n vm.swappiness) set -l zdata (zramctl --bytes --noheadings --output DATA,TOTAL /dev/zram0 2>/dev/null) -- 2.54.0 From 3d826c5407c7e15f67d06f99079c8dde0d5174b8 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Mon, 7 Sep 2026 20:07:25 -0400 Subject: [PATCH 8/9] feat(help): header-driven --help for terminal and session functions spwin, tab, hist, bkg, wake-lock and fc take the standard first-arg-only call site, so `wake-lock rsync --help` still runs rsync. split takes --help only: its own ARGUMENTS documents -h as --horizontal, so a literal test replaces the renderer's own flag check. Verified that `split -h` still selects a horizontal split and never prints help. --- functions/bkg.fish | 2 ++ functions/fc.fish | 2 ++ functions/hist.fish | 2 ++ functions/split.fish | 4 ++++ functions/spwin.fish | 2 ++ functions/tab.fish | 2 ++ functions/wake-lock.fish | 2 ++ 7 files changed, 16 insertions(+) diff --git a/functions/bkg.fish b/functions/bkg.fish index 4cd5f03..517dd27 100644 --- a/functions/bkg.fish +++ b/functions/bkg.fish @@ -23,6 +23,8 @@ # EXAMPLE # bkg firefox function bkg --description 'Execute bkg' + __fish_help_header (status current-function) $argv; and return 0 + # Check if a command was provided as an argument. if test -z "$argv[1]" set -l c_head (set_color --bold cyan) diff --git a/functions/fc.fish b/functions/fc.fish index 5abb0a1..a65e230 100644 --- a/functions/fc.fish +++ b/functions/fc.fish @@ -24,6 +24,8 @@ # fc # fc git function fc --description 'Edit and execute the last command (Bash-style fc)' + __fish_help_header (status current-function) $argv; and return 0 + set -l tmpfile (mktemp /tmp/fish_fc.XXXXXX).fish if count $argv >/dev/null diff --git a/functions/hist.fish b/functions/hist.fish index b21c97c..4536efa 100644 --- a/functions/hist.fish +++ b/functions/hist.fish @@ -17,6 +17,8 @@ # EXAMPLE # hist function hist --description 'Search fish history and put it in the prompt' + __fish_help_header (status current-function) $argv; and return 0 + # Opinionated guard (C4): integrations disabled if not __fish_config_op_enabled (status current-function) set -l c_err (set_color red) diff --git a/functions/split.fish b/functions/split.fish index 618b42a..32b2695 100644 --- a/functions/split.fish +++ b/functions/split.fish @@ -29,6 +29,10 @@ # split # split -v nvim README.md function split --description 'Run a command in a new terminal split' + # -h is --horizontal here (see this function's own ARGUMENTS), + # so only the long form may reach the renderer. + test "$argv[1]" = --help; and __fish_help_header (status current-function) --help; and return 0 + # Opinionated guard (C4): integrations disabled if not __fish_config_op_enabled (status current-function) set -l c_err (set_color red) diff --git a/functions/spwin.fish b/functions/spwin.fish index 18fb036..9068790 100644 --- a/functions/spwin.fish +++ b/functions/spwin.fish @@ -24,6 +24,8 @@ # EXAMPLE # spwin function spwin --wraps='~/.config/kitty/spawn-window.sh' --description 'spawn window in kitty or wezterm' + __fish_help_header (status current-function) $argv; and return 0 + # Opinionated guard (C4): integrations disabled if not __fish_config_op_enabled (status current-function) set -l c_err (set_color red) diff --git a/functions/tab.fish b/functions/tab.fish index 6cb9c21..6590dce 100644 --- a/functions/tab.fish +++ b/functions/tab.fish @@ -25,6 +25,8 @@ # EXAMPLE # tab function tab --description 'Spawn a new tab in the current terminal' + __fish_help_header (status current-function) $argv; and return 0 + # Opinionated guard (C4): integrations disabled if not __fish_config_op_enabled (status current-function) set -l c_err (set_color red) diff --git a/functions/wake-lock.fish b/functions/wake-lock.fish index 053556e..1c55ee8 100644 --- a/functions/wake-lock.fish +++ b/functions/wake-lock.fish @@ -22,6 +22,8 @@ # EXAMPLE # wake-lock rsync -avz src/ dest/ function wake-lock --description 'Run a command while inhibiting system sleep' + __fish_help_header (status current-function) $argv; and return 0 + if test (count $argv) -eq 0 set -l c_head (set_color --bold cyan) set -l c_cmd (set_color --bold) -- 2.54.0 From f6ead99f487a61c5fcd58471af512acd5a062e17 Mon Sep 17 00:00:00 2001 From: Rootiest Date: Mon, 7 Sep 2026 20:09:30 -0400 Subject: [PATCH 9/9] feat(help): header-driven --help for development functions, plus the wiring guard bd-pull, branch, check_fish_deps, claude-docs, claude-pr, fast, poke, qr and parur take the standard call site. fish-deps instead routes -h/--help to its existing __fish_deps_help, which previously answered only via the unknown-subcommand path, printing "Unknown subcommand: --help" and exiting 1. This completes the 40 conversions and lands test_every_user_facing_function_has_help -- acceptance criterion 6, the check that fails when a new user-facing function ships without help. It was developed red against the pre-conversion tree and reported exactly the 40 names of the design's CONVERT table: bd-pull bkg branch check_fish_deps claude-docs claude-pr cleanup fast fc fish-deps fzf-update gip gip4 gip6 hist lD ld limine-edit lock lsr lss lstree lt ltr lx parur poke ports qr sbver screensleep split spwin steam-dl sudo-toggle swapstat tab tmux-clean upgrade wake-lock each as ": no -h/--help handling and not in $__help_exempt", with the guard exiting 1. The count fell 40 -> 32 -> 24 -> 17 -> 10 -> 0 across the conversion commits. It is committed green so every commit on this branch passes the suite. $__help_exempt is the single machine-readable exemption record; a second assertion fails if an exempt name stops being a published function, so a rename or deletion upstream cannot silently exempt nothing. --- functions/bd-pull.fish | 2 ++ functions/branch.fish | 2 ++ functions/check_fish_deps.fish | 2 ++ functions/claude-docs.fish | 2 ++ functions/claude-pr.fish | 2 ++ functions/fast.fish | 2 ++ functions/fish-deps.fish | 7 ++++ functions/parur.fish | 2 ++ functions/poke.fish | 2 ++ functions/qr.fish | 2 ++ tests/functional.fish | 66 ++++++++++++++++++++++++++++++++++ 11 files changed, 91 insertions(+) diff --git a/functions/bd-pull.fish b/functions/bd-pull.fish index 87b60de..0def40d 100644 --- a/functions/bd-pull.fish +++ b/functions/bd-pull.fish @@ -23,6 +23,8 @@ # bd-pull myuser/myproject # bd-pull rootiest/fish-config function bd-pull --description 'Pull new Gitea issues into local Beads and link them' + __fish_help_header (status current-function) $argv; and return 0 + if not set -q argv[1]; echo "Need repo owner/name"; return 1; end if not set -q GITEA_TOKEN; echo "\$GITEA_TOKEN not set"; return 1; end diff --git a/functions/branch.fish b/functions/branch.fish index 836320d..6143685 100644 --- a/functions/branch.fish +++ b/functions/branch.fish @@ -21,6 +21,8 @@ # EXAMPLE # branch feature/new-ui function branch --description 'Switch to or create a git branch' + __fish_help_header (status current-function) $argv; and return 0 + if not git rev-parse --is-inside-work-tree >/dev/null 2>&1 echo "Not a git repo." return 1 diff --git a/functions/check_fish_deps.fish b/functions/check_fish_deps.fish index 9bae21b..fd24581 100644 --- a/functions/check_fish_deps.fish +++ b/functions/check_fish_deps.fish @@ -14,5 +14,7 @@ # EXAMPLE # check_fish_deps function check_fish_deps --description 'Check all fish-related dependencies' + __fish_help_header (status current-function) $argv; and return 0 + fish-deps status end diff --git a/functions/claude-docs.fish b/functions/claude-docs.fish index a4979b5..1d7717b 100644 --- a/functions/claude-docs.fish +++ b/functions/claude-docs.fish @@ -15,5 +15,7 @@ # EXAMPLE # claude-docs function claude-docs --description 'Claude-code: Sync README with recent changes' + __fish_help_header (status current-function) $argv; and return 0 + claude "Analyze the recent changes and update the README.md to ensure all features, setup instructions, and examples are 100% accurate. Prune any obsolete information." end diff --git a/functions/claude-pr.fish b/functions/claude-pr.fish index 507d753..d38f36e 100644 --- a/functions/claude-pr.fish +++ b/functions/claude-pr.fish @@ -15,5 +15,7 @@ # EXAMPLE # claude-pr function claude-pr --description 'Claude-code: New branch, commit, push, and PR' + __fish_help_header (status current-function) $argv; and return 0 + claude "Act as a senior engineer. Execute this sequence: 1. Create a new git branch (kebab-case). 2. Stage changes and write a Conventional Commit message. 3. Self-verify the changes by running relevant build/test commands or linting. 4. Push to remote. 5. Create a PR to 'main' including a summary of changes and a 'Manual Verification' section containing a Markdown checklist (- [ ]) of specific, bite-sized steps required to manually verify the functionality." end diff --git a/functions/fast.fish b/functions/fast.fish index 5cc0dfe..7fec3cb 100644 --- a/functions/fast.fish +++ b/functions/fast.fish @@ -36,6 +36,8 @@ # EXAMPLE # fast function fast --description 'Placeholder for future fast utility' + __fish_help_header (status current-function) $argv; and return 0 + # ANSI Escape Codes (Standard 16-color palette) set -l bold "\e[1m" set -l italic "\e[3m" diff --git a/functions/fish-deps.fish b/functions/fish-deps.fish index bf9839e..1e244ab 100644 --- a/functions/fish-deps.fish +++ b/functions/fish-deps.fish @@ -66,6 +66,13 @@ function fish-deps --description 'Manage fish shell dependencies' _fish_deps_status case install _fish_deps_install $flags + case -h --help + # Reuse the existing menu rather than the header renderer: it + # is richer, and it is already the text the unknown-subcommand + # path prints. Previously --help fell into `case '*'` and + # exited 1 with "Unknown subcommand: --help". + __fish_deps_help + return 0 case update _fish_deps_update case sync diff --git a/functions/parur.fish b/functions/parur.fish index 62a65e1..6f6f92c 100644 --- a/functions/parur.fish +++ b/functions/parur.fish @@ -19,6 +19,8 @@ # EXAMPLE # parur function parur --description 'Interactively search and remove an installed package using fzf' + __fish_help_header (status current-function) $argv; and return 0 + set -l aur "" if type -q paru set aur paru diff --git a/functions/poke.fish b/functions/poke.fish index 8cff1c5..ea9bd59 100644 --- a/functions/poke.fish +++ b/functions/poke.fish @@ -21,6 +21,8 @@ # EXAMPLE # poke ~/projects/new/src/main.fish function poke --description 'touch with automatic parent directory creation' + __fish_help_header (status current-function) $argv; and return 0 + if test (count $argv) -eq 0 echo (set_color red)"poke: no file specified"(set_color normal) >&2 return 1 diff --git a/functions/qr.fish b/functions/qr.fish index 804ded5..50af5d7 100644 --- a/functions/qr.fish +++ b/functions/qr.fish @@ -19,6 +19,8 @@ # qr "https://example.com" # echo "hello" | qr function qr --description 'Generate a QR code from text or pipe' + __fish_help_header (status current-function) $argv; and return 0 + if type -q qrencode if set -q argv[1] echo $argv | qrencode -t utf8 diff --git a/tests/functional.fish b/tests/functional.fish index f027194..759188b 100644 --- a/tests/functional.fish +++ b/tests/functional.fish @@ -289,6 +289,72 @@ function test_help_never_executes_destructive_path test $failed -eq 0 end +# Functions published in the manual that are exempt from the -h/--help +# rule. Rationale per entry: AGENTS/specs/2026-09-07-header-driven-help-design.md +# §4. This array is the ONLY machine-readable copy of the exempt set. +# +# EXEMPT-A -- shadows a same-named binary, or forwards $argv to one named +# tool that owns its own --help. Intercepting would hide that tool's help, +# and for the C1-guarded shadows it also breaks the disabled-fallback +# contract, where the bare tool is supposed to answer. +set -g __help_exempt \ + agy antigravity-ide bash cat cdi cffetch cheat claude clone clonet \ + config-toggle copy docker du dusize fast-cli ffetch gitui gitup jr \ + joplin less ls mkdir mv paste ping rawfish rg rm search ssh top \ + view yt-dlp +# EXEMPT-B -- invoked by fish, never typed by a user. +set -a __help_exempt fish_prompt fish_right_prompt fish_mode_prompt \ + sponge_filter_secrets + +function test_every_user_facing_function_has_help + set -l root (realpath (dirname (status filename))/..) + set -l failed 0 + set -l published + + for f in $root/functions/*.fish + set -l lines (string split \n -- (command cat $f)) + # Published == carries a `# CATEGORY` block, matching + # manualtools.parse_functions. + contains -- "# CATEGORY" (string trim -- $lines); or continue + # Resolve the real defined name; the file stem can disagree + # (dops.fish defines `docker` -- see JOB-BRIEF-FINDINGS.md §1). + set -l name (string match -rg '^\s*function\s+(\S+)' -- $lines)[1] + test -n "$name"; or continue + set name (string trim -c "'\"" -- $name) + string match -q '_*' -- $name; and continue + set -a published $name + + contains -- $name $__help_exempt; and continue + + # Body == everything from the `function` line down, comment lines + # dropped, so a header that merely mentions --help cannot pass. + set -l body + set -l in_body 0 + for l in $lines + test $in_body -eq 1; or string match -qr '^\s*function\s' -- $l; and set in_body 1 + test $in_body -eq 1; or continue + string match -qr '^\s*#' -- $l; and continue + set -a body $l + end + if not string match -qr -- '__fish_help_header|_flag_help|h/help|--help' \ + (string join \n -- $body) + echo " $name: no -h/--help handling and not in \$__help_exempt" + set failed 1 + end + end + + # Guard against a stale exempt list: every exempt name must still be a + # published function. Catches renames and deletions. + for e in $__help_exempt + if not contains -- $e $published + echo " \$__help_exempt lists '$e', which is no longer published" + set failed 1 + end + end + + test $failed -eq 0 +end + function functional_test_main set -l names (functions -a | string match 'test_*' | sort) set -l failed 0 -- 2.54.0