Triggering MacOS Notifications inside the Unix Pipeline with Tee, Grep, and Xargs

Triggering MacOS Notifications inside the Unix Pipeline with Tee, Grep, and Xargs
Reading Time: 6 minutes

The other day I thought “I really wish I could get a notification when this string pops up in the logs. Is there something like the tee command mixed with grep that fires MacOS notifications?” Yes, my thoughts are usually this mundane and obscure.

I searched the web hoping for likeminded people who’d had a similar obscure, mundane thought and were motivated to do something about it. Unfortunately my thought had been truly obscure. Fine, I’ll see if I can do it. We sit on a mountain of incredible tools, and it’s fun to root around in them and slap something together. It was easier than I expected it to be! Here’s the result of my idle wish:

teeshout () 
  tee >(\
    egrep --line-buffered "$*" |\
    xargs -I % -L1 osascript -e "display notification \"%\" with title \"teeshout\" "\

I think that bash function will work in MacOS without requiring any additional software. The osascript can be replaced with terminal-notifier or something similar for Linux, or whatever command you’d like to run (send an email, stop the process, etc).

After I got this tiny utility working, my actual first use case was to tell me when a Dockerized Laravel app was ready. The time between startup and visitable in the browser can be almost 45 seconds! Unbearable! Just long enough for me to get distracted by something, so the popup helps bring me back to the task at hand:

$ docker-compose up | teeshout "Laravel development server started on"

Originally, I wanted this while tail-ing a running logfile and interacting with a web app, hoping to trigger certain events. Clicking while keeping my eye on the logging was a pain. Anyway, I’ve added teeshout to my bash config and we’ll see how often I use it in the coming weeks.

(Yes, I see the [32m in the notification; that’s the terminal escape codes for color highlighting. Filtering those out is left as an exercise for some day.)

Scratching an Itch

Putting this together was an interesting way to spend an hour. I searched the web, skimmed manpages, and fumbled around enjoying the process. Again, you learn a few things when you try to do something new with the tools available. My biggest takeaway: commands tend to buffer output when used in a pipeline, because they’re optimizing for completing the processing of finite input but not for interactively processing a stream. I had to deal with that (STDOUT.sync=true, --line-buffered) several times.


First, I needed input to test with so I decided to go with a Ruby one-liner:

$ ruby -e 'STDOUT.sync=true; while(true); puts Time.now; sleep 1; end'
2020-07-15 14:21:29 -0400
2020-07-15 14:21:30 -0400
2020-07-15 14:21:31 -0400

That spits out the time every second. The STDOUT.sync=true at the beginning is important because Ruby (and it turns out other tools like grep, below) buffer output when it is not going straight to the terminal; run that command directly and it’s all chatty, but when piped to another command things get real quiet.

$ ruby -e 'while(true); puts Time.now; sleep 1; end' | cat

Some headscratching and then I remembered hitting this with a Ruby script before, searched around the web, found and added the sync bit to disable the buffering, and hooray things lit up again!

$ ruby -e 'STDOUT.sync=true; while(true); puts Time.now; sleep 1; end' | cat
2020-07-17 12:19:03 -0400
2020-07-17 12:19:04 -0400

Splitting the stream

My mundane dream was to see the entire output flow by in a terminal while being alerted only for certain text, when my attention wanders. When I think of intercepting a stream I think of the tee command, which, according to the manpage “…copies standard input to standard output, making a copy in zero or more files. The output is unbuffered.”

It’s named metaphorically for a T-shaped pipe fitting. The command ls -1 | tee foo.txt will list files in a directory and create foo.txt with the contents as well. Oh and “output is unbuffered” makes me very happy in retrospect, as again buffering output bit me several times as I worked on this.

Of course I don’t want the output to go to the terminal and then to “zero or more files”, I want it to go to the terminal and another command that sends me notifications. Thankfully this is Unix-land, where everything can be a file if you believe: bash process substitution to the rescue in this case. When bash sees command1 >(command2) it creates a file descriptor that command1 can write to and command2 can read from.

So tee will be command1 here, passing my target output to the terminal, and command2 will be my filtering and notification commands.

$ ruby -e 'STDOUT.sync=true; while(true); puts Time.now; sleep 1; end' | tee >([INSERT AWESOME STUFF HERE])

[this obviously won’t do anything yet]


So what exactly will we put in the “INSERT AWESOME STUFF HERE” slot? I want to look for and trigger on certain lines only. grep is the tool I usually reach for when filtering by content. Trying it out with a test and it looks good!

$ ruby -e 'STDOUT.sync=true; while(true); puts Time.now; sleep 1; end' | tee >(egrep ":\d[135] ")
2020-07-15 15:57:01 -0400
2020-07-15 15:57:01 -0400
2020-07-15 15:57:02 -0400
2020-07-15 15:57:03 -0400
2020-07-15 15:57:03 -0400

I’m teeing the output from the ruby command and also grepping for certain lines, so only matching lines will be doubled in the output: printed by tee and then printed by the grep when matched.

Line By Line

Now we need a way for egrep to invoke another notification command for every match. In other words, we need a way to execute a command for every line coming through standard input. A web search for “execute command once per line pipe” led to me biggest aha/duh! moment:

That’s what xargs does.


Let’s try it:

$ ruby -e 'STDOUT.sync=true; while(true); puts Time.now; sleep 1; end' | tee >(egrep ":\d[135]" | xargs echo)

Why no doubled lines? Am I using xargs wrong or is….wait, is egrep buffering stuff sometimes? Let’s look at the manpage:

–line-buffered Force output to be line buffered. By default, output is line buffered when standard output is a terminal and block buffered otherwise.

Aha! I’ll add that. But, nope.

$ ruby -e 'STDOUT.sync=true; while(true); puts Time.now; sleep 1; end' | tee >(egrep --line-buffered ":\d[135]" | xargs echo)

Ok what’s the xargs manpage got?

-L number Call utility for every number non-empty lines read….

Hmm so -L 1 then? Let’s see. Success!

$ ruby -e 'STDOUT.sync=true; while(true); puts Time.now; sleep 1; end' | tee >(egrep --line-buffered ":\d[135]" | xargs -L 1 echo)
2020-07-17 12:29:41 -0400
2020-07-17 12:29:41 -0400
2020-07-17 12:29:42 -0400
2020-07-17 12:29:43 -0400
2020-07-17 12:29:43 -0400


Now, we need to replace echo with something a bit shoutier. How do you send MacOS notifications from the commandline anyway? A websearch later leads me to:

osascript -e 'display notification "Hello World" with title "Title Here"'

Let’s see if we can get that situated so that xargs can call it. Of course, xargs usually takes the input and tacks it onto the end of the command you give it, but we need the output carefully situated in quotes for osascript. Let’s look through the manpage again:

-I replstr Execute utility for each input line, replacing one or more occurrences of replstr…

$ ruby -e 'STDOUT.sync=true; while(true); puts Time.now; sleep 1; end' | tee >(egrep --line-buffered ":\d[135]" | xargs -I % -L 1 osascript -e 'display notification "%" with title "hey!"')
2020-07-17 12:36:50 -0400
2020-07-17 12:36:51 -0400
2020-07-17 12:36:52 -0400
2020-07-17 12:36:53 -0400

Wow! It works!

Put it in the toolbox

I’ve got the core of something, but I need to make it reusable. Bash aliases and functions are good for taking a one-off and putting it into the “toolbox” for later. So, if you’ve made it this far with me, let’s revisit the snippet from the beginning. It’s a packaging of my final successful test, genericized by accepting input to the function ($*) for the filter:

teeshout () 
  tee >(\
    egrep --line-buffered "$*" |\
    xargs -I % -L1 osascript -e "display notification \"%\" with title \"teeshout native\" "\

Let’s test it out:

$ ruby -e 'STDOUT.sync=true; while(true); puts Time.now; sleep 1; end' | teeshout "[135]: "
2020-07-17 12:41:44 -0400
2020-07-17 12:41:45 -0400

It works! I’ve scratched an itch!