Skip to content

feat: add external-command hooks (pipe-to, exec_on_new, macros)#39

Merged
bahdotsh merged 6 commits into
mainfrom
feat/3-external-command-hooks
May 13, 2026
Merged

feat: add external-command hooks (pipe-to, exec_on_new, macros)#39
bahdotsh merged 6 commits into
mainfrom
feat/3-external-command-hooks

Conversation

@bahdotsh
Copy link
Copy Markdown
Owner

@bahdotsh bahdotsh commented May 7, 2026

Summary

Adds three newsboat-style extensibility primitives so users can self-serve the long tail of "save to X / notify on Y / pipe to Z" workflows without feedr chasing one-off integrations:

  • pipe-to — pipes the focused article (body / title / url / metadata, configurable per macro step) to a shell command's stdin. The TUI is suspended for the duration so pagers and editors take over the terminal cleanly.
  • exec_on_new — fires a per-item command after each refresh. The first successful fetch of a feed seeds the seen set silently, so enabling this on a 200-item feed doesn't notify-spam.
  • Macros — bind a chord (default prefix , then key) to an ordered chain of actions / pipe-to / exec steps. Newsboat-compatible string syntax (open-in-browser ; pipe-to "yt-dlp %u"); existing newsboat macros mostly paste in directly.

Design notes

Safety stance. Templates are tokenized once via shlex::split and %X placeholders are substituted into individual argv slots. Commands are not run through sh -c. That means pipe-to "tee out.txt | wc -l" won't pipeline — wrap it in a script. The alternative is letting a malicious feed title execute arbitrary commands, which is not a trade I was willing to make. Documented in the auto-generated config comments.

Sequential macro semantics. All three step kinds (Action, PipeTo, Exec) are queued onto app.pending_macro_steps (FIFO) and drained in order by the TUI loop. Earlier draft ran Action steps inline and queued only the externals — that broke pipe-to "X" ; toggle-read ordering. Refactored before commit.

Terminal suspend. tui::suspend_for_command does the verified ratatui dance: LeaveAlternateScreen → DisableMouseCapture → disable_raw_mode → spawn → wait → enable_raw_mode → EnterAlternateScreen → EnableMouseCapture → terminal.clear(), with an RAII guard so a panicking child can't strand the user in raw mode. Pending input drained on return to absorb terminal-probe responses (less/vim background-color queries).

exec_on_new first-fetch suppression. A new persisted feeds_seeded: HashSet<String> records URLs that have been fetched at least once. The very first successful fetch silently seeds seen_items without firing hooks. Persists across restarts via feedr_data.json (additive serde(default) field, fully back-compat with v0.7.0 data files).

Config schema (all serde(default))

[hooks]
exec_on_new = 'notify-send "New: %t" "%f"'

[macros]
y = 'open-in-browser ; pipe-to "yt-dlp %u"'
w = 'pipe-to "wallabag-cli add %u" -- "Save to Wallabag"'
n = 'pipe-to "tee out.txt" stdin=metadata'

[macro_options]
prefix = ","
pipe_default_stdin = "body"  # body | title | url | metadata | none

Template variables: %t title, %u url, %a author, %d date, %f feed-title, %F feed-url, %% literal %.

Files touched

  • src/keybindings.rsMacroStep, MacroBinding, StdinKind, MacroOptions, parse_macro_string, build_macros, binding_display helper
  • src/config.rs[hooks], [macros], [macro_options] sections
  • src/app.rsseen_items/feeds_seeded persistence, ArticleContext, current_article_context, mark_feed_seen, expand_argv_template, make_pipe_payload
  • src/events.rs — macro-prefix detection in handle_key_event, run_macro queueing, dispatch_action
  • src/tui.rssuspend_for_command, spawn_detached, drain_macro_steps, fire_exec_on_new with first-fetch suppression
  • src/ui/modals.rs — Macros section in the help overlay
  • Cargo.tomlshlex = "1.3" (for argv tokenization)

Test plan

  • cargo test --lib — 103 tests passing (12 new for macro parser, 8 new for app helpers, 2 new for config back-compat / round-trip)
  • cargo test --tests — 4 integration tests passing
  • cargo clippy --all-targets --all-features -- -D warnings — clean
  • cargo fmt --all -- --check — clean
  • cargo build --release — clean
  • Manual smoke test: with [macros] x = 'pipe-to "cat"', press ,x on an article — body appears in stdout, terminal restores cleanly on Ctrl+D.
  • Manual smoke test: with [hooks] exec_on_new = 'tee -a /tmp/feedr.log', refresh — log file is silent on first fetch of a feed, then gets one line per genuinely new item on subsequent refreshes.
  • Manual smoke test: [macros] y = 'open-in-browser ; pipe-to "yt-dlp %u"' on a YouTube link — opens browser, then pipes URL to yt-dlp.

Notes for reviewers

bahdotsh added 4 commits May 7, 2026 13:28
Users keep asking for "save to wallabag", "pipe to yt-dlp",
"notify on new article", and so on. Chasing one integration at
a time is a losing game.

So instead, three newsboat-style primitives:

  * `pipe-to` pipes the focused article to a shell command's
    stdin (body / title / url / metadata, configurable), with
    the TUI suspended so pagers and editors behave.
  * `exec_on_new` fires a command per newly-seen item after
    each refresh. The first fetch of a feed is seeded silently
    so enabling this on a 200-item feed doesn't notify-spam
    you into oblivion.
  * Macros bind a chord (default prefix `,` then key) to a
    sequence of actions. Newsboat-compatible string syntax,
    so you can paste their configs.

Templates are tokenized once via shlex and `%X` is substituted
into individual argv slots — *not* run through `sh -c`. That
means you can't put a `;` in your template and have a pipeline.
Wrap it in a script. The alternative is letting a malicious
feed title execute arbitrary commands, and that's not a trade
I'm willing to make.

Macros queue every step (Action / PipeTo / Exec) onto a FIFO
list which the TUI loop drains in order, so newsboat-style
sequential semantics are preserved across blocking steps. The
terminal-suspend dance uses an RAII guard so a panicking child
can't leave the user stuck in raw mode.

Config and SavedData are fully back-compat — all new sections
and fields are \`serde(default)\`. 103 unit tests + 4 integration
tests pass; clippy clean.
Six bugs and a pile of polish from the principal review on the
hooks branch. Nothing here changes the user-facing shape of macros
or exec_on_new — it's all the things that go wrong when you stare
at the code for an hour.

The biggest one: `exec_on_new` could fire twice for the same item
after a crash. The reason was that `mark_feed_seen` updates the
in-memory seen-set as each feed arrives in a refresh batch, but
`save_data` only ran at the end of the batch. Kill feedr mid-batch
and the persisted seen-set is stale — next launch, the same item
gets re-fired. For a `notify-send` hook that's annoying. For
`wallabag-cli add` it's actively wrong. Persist before the spawn
loop. At-most-once beats at-least-once when you're firing arbitrary
external commands.

The next one: the macro prefix had no escape hatch. Press `,` and
walk away, and the next keystroke is silently consumed as a macro
follow-up — including a `j` you meant as move-down. ESC now
cancels cleanly, the prefix wait times out on tick, and unmatched
follow-ups still error rather than falling through, because I'd
rather surface a typo than silently jump-top.

While at it: pre-tokenize the exec_on_new template once at startup
instead of re-shlex-ing it for every feed arrival; surface friendly
dashed action names (`open-in-browser` not `OpenInBrowser`) in
errors and the help overlay; track first-failure with a local bool
so the comment matches the code; switch `pending_macro_steps` to a
VecDeque so the drain isn't O(n) per step. Tidy the byte-level
\` -- \` separator check while I was in there.

Inline help in `config.rs` now spells out the supported actions
inside macros, warns that \`~\`/\`\$HOME\` are *not* expanded (no shell,
no expansion, please don't be surprised), points out that wrapping
in \`sh -c\` re-introduces shell injection through item titles, and
documents the at-most-once semantics for exec_on_new so users
choose idempotent commands.

Tests for the new behavior: ESC cancellation, prefix-then-unbound,
prefix-then-bound, \`dispatch_action\` rejecting unsupported actions
with the friendly name, the \`KeyAction::as_str\` <-> \`FromStr\`
round-trip so future variants can't silently drift, and a
SavedData round-trip for the new persistence fields plus back-compat
with the old shape. 110 tests pass, clippy clean with -D warnings.
A code review on top of the first review-feedback commit turned up
three things that are *not* OK to ship, plus a few smaller ones.

The big one: dispatch_action read app.selected_feed for ToggleStar /
ToggleRead, but selected_feed is only ever set when you drill into
FeedItems or FeedItemDetail. So a macro like `,s = toggle-star`
silently no-op'd on Dashboard and Starred views. The per-key
handlers in those views *do* resolve the focused item correctly via
active_dashboard_items() / get_starred_dashboard_items(), but the
macro path was happily ignoring all of that. Two entry points,
two answers for "which item is focused." Not great.

Add App::current_article_indices() as the single source of truth,
have current_article_context() delegate to it, and route the toggle
helpers through it. Now keypress and macro agree on what "current"
means.

The other big one: spawn_detached spawned a child and dropped the
Child without ever waiting on it. On Unix this leaves the kernel
holding a zombie until the parent exits. Feedr is a long-running
TUI; a user with exec_on_new = 'notify-send …' and a handful of
active feeds would accumulate <defunct> processes indefinitely.
Stick the Child in a reaper thread that just waits. One thread per
child is fine for our scale and it's cross-platform.

While at it: fire_exec_on_new was mutating seen_items and calling
save_data() on *every* feed arrival even when no hook was
configured, which means users who never opted in were paying for
an unbounded growing set on disk for absolutely no reason. Short-
circuit at the top when the template is None.

Also: suspend_for_command was writing the stdin payload
synchronously on the main thread before waiting on the child, which
can deadlock if the payload is bigger than the pipe buffer (64 KiB
on Linux) and the child hasn't started reading yet. Spawn a writer
thread, let it run in parallel with child.wait(). Drop the dead
stdin_payload parameter from spawn_detached while I'm in there —
both call sites pass None.

Add regression tests for the two dispatch_action cases. Fix a
stale doc comment referencing a macro_prefix_timeout config that
doesn't exist.
drain_macro_steps documented "on first error the rest of the queue is
dropped" but only PipeTo spawn failures actually obeyed: Exec spawn
failures and Action errors (e.g. open-in-browser with no URL) silently
continued through the chain.

A chain like `open-in-browser ; toggle-star` should not silently
toggle-star when the browser launch failed — the user only sees the
error and would not expect side-effecting follow-up steps to have run.

Track app.error before each step and clear/break the queue when a
step introduces a new error. Same break applied to Exec spawn failures
for parity with PipeTo.

Three drain-path tests added covering halt-on-action-error, clean-chain
runs every step, and that a preexisting error isn't mistaken for a
step-introduced failure.
Copy link
Copy Markdown
Owner Author

@bahdotsh bahdotsh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

.

bahdotsh added 2 commits May 13, 2026 17:53
Four things turned up on the third pass through the hooks branch.
Two of them were latent bugs the earlier reviews missed; two were
the kind of write-amplification and resource-leak issues that don't
bite anyone in a 100-test suite but get embarrassing in production.

The biggest one: a `,r = toggle-read` macro on the Dashboard with
"unread only" filtered didn't drop the just-read item from the
list. The Dashboard keypress called `apply_filters()` inline; the
macro path went through `dispatch_action -> handle_toggle_read_current`
and silently skipped it. Two execution paths, two answers for "did
the dashboard refresh." Move `apply_filters()` into the helper so
every caller -- macro, FeedItems, FeedItemDetail -- agrees.

Next: `mark_feed_seen` flipped `feeds_seeded` even when the first
fetch returned zero items. So a transiently-empty fetch (server
hiccup, parser edge case) silently armed the firehose: the *next*
non-empty fetch treated every backlog item as new. The first-fetch
suppression existed precisely to prevent this. One-line guard on
`!feed.items.is_empty()`. Please don't congratulate yourself for
seeding a feed you haven't actually seen.

Then: `fire_exec_on_new` ran `save_data()` once per feed arrival.
With 50 bookmarks and the hook configured, that's 50 whole-file
JSON writes per refresh. Split into `collect_exec_on_new`
(in-memory only) and `flush_exec_on_new` (one save, then spawn);
the receive loop accumulates argv across the batch and flushes
once. AT-MOST-ONCE crash semantics are preserved -- save still
happens before any spawn, just at batch granularity now.

Finally: `remove_current_feed` never dropped the URL from
`feeds_seeded` or its item IDs from `seen_items`. So the persisted
JSON grew monotonically across feed churn for absolutely no reason.
Drop them before removing the feed itself.

Three regression tests added: dashboard filter reapply after macro
toggle-read, empty-first-fetch suppression, and feed removal
cleanup. 118 tests pass, clippy clean, fmt clean.
Five commits of feature work landed on this branch and the docs
never caught up. Anyone reading the README would have no idea
feedr now does newsboat-style macros, pipe-to, exec, or
exec_on_new notifications. CLAUDE.md still described the codebase
as if none of this existed.

Let's fix that.

README gets a Features bullet plus a dedicated subsection covering
the no-shell argv expansion model, the %X template variables,
macro chain syntax + step kinds, exec_on_new with its at-most-once
crash semantics, and the security footguns. The `sh -c "... %t..."`
warning is worth repeating outside of the in-code config comments
— that is the one mistake that turns a safe feature into shell
injection through item titles, and I would rather say it twice
than once.

CLAUDE.md gets two new "Key patterns" bullets explaining the
engine architecture future-me will want: queue-fill in events.rs,
drain in tui.rs because pipe-to needs the terminal handle to
suspend the TUI; save-then-spawn ordering for at-most-once
semantics; first-fetch seeding to prevent the firehose;
transiently-empty-fetch guard so we do not seed prematurely.
Module descriptions updated to mention the new responsibilities.

Not glamorous, but the feature is not shipped until the docs say
it is.
@bahdotsh bahdotsh merged commit 4585381 into main May 13, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant