Skip to content

Commit

Permalink
shell integration for bash & zsh! closes #22
Browse files Browse the repository at this point in the history
  • Loading branch information
dschep committed Feb 20, 2016
1 parent b84c852 commit dfddd34
Show file tree
Hide file tree
Showing 4 changed files with 316 additions and 1 deletion.
22 changes: 22 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,28 @@ Usage
# this send the message '"sleep 10" succeeded in 0:10 minutes'
ntfy done sleep 10

Shell integration
~~~~~~~~~~~~~~~~~~~~
``ntfy`` has support for **automatically** sending notifications when long
running commands finish in Bash and ZSH. To enable it add the following to your
``.bashrc`` or ``.zshrc``:

::

eval $(ntfy shell-integration)

By default it will only send notifications for commands lasting longer than 30
seconds. This can be configured with the ``AUTO_NTFY_DONE_TIMEOUT`` environment
variable.

To avoid unnecessary notifications when running interactive programs programs
listed in ``AUTO_NTFY_DONE_IGNORE`` don't generate notifications. for example:

::

export AUTO_NTFY_DONE_IGNORE="vim screen meld"


Backends
--------

Expand Down
21 changes: 20 additions & 1 deletion ntfy/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import logging
import logging.config
from getpass import getuser
from os import path
from os import path, environ
from socket import gethostname
from subprocess import call
from sys import exit
Expand Down Expand Up @@ -32,6 +32,15 @@ def run_cmd(args):
'failed', *map(int, divmod(duration, 60)))


def auto_done(args):
shell_path = path.join(path.split(__file__)[0], 'shell_integration')
if args.shell == 'bash':
print('source {}/bash-preexec.sh'.format(shell_path))
print('source {}/auto-ntfy-done.sh'.format(shell_path))
print("# To use ntfy's shell integration, run:")
print('# eval "$(ntfy shell-integration)"')


parser = argparse.ArgumentParser(
description='Send push notification when command finishes')

Expand Down Expand Up @@ -106,6 +115,16 @@ def run_cmd(args):
help="Only notify if the command runs longer than N seconds")
done_parser.set_defaults(func=run_cmd)

shell_integration_parser = subparsers.add_parser(
'shell-integration',
help='automatically get notifications when long running commands finish')
shell_integration_parser.add_argument(
'-s',
'--shell',
default=path.split(environ.get('SHELL', ''))[1],
choices=['bash', 'zsh'],
help='The shell to integrate ntfy with (default: your login shell)')
shell_integration_parser.set_defaults(func=auto_done)

def main(cli_args=None):
if cli_args is not None:
Expand Down
45 changes: 45 additions & 0 deletions ntfy/shell_integration/auto-ntfy-done.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# In bash this requires https://github.com/rcaloras/bash-preexec
# If sourcing this via ntfy auto-done, it is sourced for you.

# Default timeout is 30 seconds.
AUTO_NTFY_DONE_TIMEOUT=${AUTO_NTFY_DONE_TIMEOUT:-30}
# Default to ignoring some well known interactive programs
AUTO_NTFY_DONE_IGNORE=${AUTO_NTFY_DONE_IGNORE:-vi vim nano emacs screen tmux ssh less tail man meld}
# Bash option example
#AUTO_NTFY_DONE_OPTS='-b linux'
# Zsh option example
#AUTO_NTFY_DONE_OPTS=(-b linux)

function ntfy_precmd () {
[ -n "$ntfy__start_time" ] || return
local duration=$(( $(date +%s) - $ntfy__start_time ))
ntfy__start_time=''
[ $duration -gt $AUTO_NTFY_DONE_TIMEOUT ] || return

local appname=$(basename "${ntfy__command%% *}")
[[ " $AUTO_NTFY_DONE_IGNORE " == *" $appname "* ]] && return

local human_duration=$(printf '%d:%02d\n' $(($duration/60)) $(($duration%60)))
local human_retcode
[ "$ret_value" -eq 0 ] && human_retcode='succeeded' || human_retcode='failed'
ntfy $AUTO_NTFY_DONE_OPTS send "\"$ntfy__command\" $human_retcode in $human_duration minutes"
}

function ntfy_preexec () {
ntfy__start_time=$(date +%s)
ntfy__command=$(echo "$1")
}

function contains_element() {
local e
for e in "${@:2}"; do [[ "$e" == "$1" ]] && return 0; done
return 1
}

if ! contains_element ntfy_preexec "${preexec_functions[@]}"; then
preexec_functions+=(ntfy_preexec)
fi

if ! contains_element ntfy_precmd "${precmd_functions[@]}"; then
precmd_functions+=(ntfy_precmd)
fi
229 changes: 229 additions & 0 deletions ntfy/shell_integration/bash-preexec.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,229 @@
#!/bin/bash
#
# bash-preexec.sh -- Bash support for ZSH-like 'preexec' and 'precmd' functions.
# https://github.com/rcaloras/bash-preexec
#
#
# 'preexec' functions are executed before each interactive command is
# executed, with the interactive command as its argument. The 'precmd'
# function is executed before each prompt is displayed.
#
# Author: Ryan Caloras (ryan@bashhub.com)
# Forked from Original Author: Glyph Lefkowitz
#
# V0.2.3
#

# General Usage:
#
# 1. Source this file at the end of your bash profile so as not to interfere
# with anything else that's using PROMPT_COMMAND.
#
# 2. Add any precmd or preexec functions by appending them to their arrays:
# e.g.
# precmd_functions+=(my_precmd_function)
# precmd_functions+=(some_other_precmd_function)
#
# preexec_functions+=(my_preexec_function)
#
# 3. If you have anything that's using the Debug Trap, change it to use
# preexec. (Optional) change anything using PROMPT_COMMAND to now use
# precmd instead.
#
# Note: This module requires two bash features which you must not otherwise be
# using: the "DEBUG" trap, and the "PROMPT_COMMAND" variable. prexec_and_precmd_install
# will override these and if you override one or the other this will most likely break.

# Avoid duplicate inclusion
if [[ "$__bp_imported" == "defined" ]]; then
return 0
fi
__bp_imported="defined"


# Remove ignorespace and or replace ignoreboth from HISTCONTROL
# so we can accurately invoke preexec with a command from our
# history even if it starts with a space.
__bp_adjust_histcontrol() {
local histcontrol
histcontrol="${HISTCONTROL//ignorespace}"
# Replace ignoreboth with ignoredups
if [[ "$histcontrol" == *"ignoreboth"* ]]; then
histcontrol="ignoredups:${histcontrol//ignoreboth}"
fi;
export HISTCONTROL="$histcontrol"
}

# This variable describes whether we are currently in "interactive mode";
# i.e. whether this shell has just executed a prompt and is waiting for user
# input. It documents whether the current command invoked by the trace hook is
# run interactively by the user; it's set immediately after the prompt hook,
# and unset as soon as the trace hook is run.
__bp_preexec_interactive_mode=""

__bp_trim_whitespace() {
local var=$@
var="${var#"${var%%[![:space:]]*}"}" # remove leading whitespace characters
var="${var%"${var##*[![:space:]]}"}" # remove trailing whitespace characters
echo -n "$var"
}

# This function is installed as part of the PROMPT_COMMAND;
# It sets a variable to indicate that the prompt was just displayed,
# to allow the DEBUG trap to know that the next command is likely interactive.
__bp_interactive_mode() {
__bp_preexec_interactive_mode="on";
}


# This function is installed as part of the PROMPT_COMMAND.
# It will invoke any functions defined in the precmd_functions array.
__bp_precmd_invoke_cmd() {

# Should be available to each precmd function, should it want it.
local ret_value="$?"

# For every function defined in our function array. Invoke it.
local precmd_function
for precmd_function in "${precmd_functions[@]}"; do

# Only execute this function if it actually exists.
# Test existence of functions with: declare -[Ff]
if type -t "$precmd_function" 1>/dev/null; then
__bp_set_ret_value $ret_value
$precmd_function
fi
done
}

# Sets a return value in $?. We may want to get access to the $? variable in our
# precmd functions. This is available for instance in zsh. We can simulate it in bash
# by setting the value here.
__bp_set_ret_value() {
return $1
}

__bp_in_prompt_command() {

local prompt_command_array
IFS=';' read -ra prompt_command_array <<< "$PROMPT_COMMAND"

local trimmed_arg
trimmed_arg=$(__bp_trim_whitespace "$1")

local command
for command in "${prompt_command_array[@]}"; do
local trimmed_command
trimmed_command=$(__bp_trim_whitespace "$command")
# Only execute each function if it actually exists.
if [[ "$trimmed_command" == "$trimmed_arg" ]]; then
return 0
fi
done

return 1
}

# This function is installed as the DEBUG trap. It is invoked before each
# interactive prompt display. Its purpose is to inspect the current
# environment to attempt to detect if the current command is being invoked
# interactively, and invoke 'preexec' if so.
__bp_preexec_invoke_exec() {

if [[ -n "$COMP_LINE" ]]
then
# We're in the middle of a completer. This obviously can't be
# an interactively issued command.
return
fi
if [[ -z "$__bp_preexec_interactive_mode" ]]
then
# We're doing something related to displaying the prompt. Let the
# prompt set the title instead of me.
return
else
# If we're in a subshell, then the prompt won't be re-displayed to put
# us back into interactive mode, so let's not set the variable back.
# In other words, if you have a subshell like
# (sleep 1; sleep 2)
# You want to see the 'sleep 2' as a set_command_title as well.
if [[ 0 -eq "$BASH_SUBSHELL" ]]
then
__bp_preexec_interactive_mode=""
fi
fi

if __bp_in_prompt_command "$BASH_COMMAND"; then
# If we're executing something inside our prompt_command then we don't
# want to call preexec. Bash prior to 3.1 can't detect this at all :/

__bp_preexec_interactive_mode=""
return
fi

local this_command
this_command=$(HISTTIMEFORMAT= history 1 | { read -r _ this_command; echo "$this_command"; })

# Sanity check to make sure we have something to invoke our function with.
if [[ -z "$this_command" ]]; then
return
fi

# If none of the previous checks have returned out of this function, then
# the command is in fact interactive and we should invoke the user's
# preexec functions.

# For every function defined in our function array. Invoke it.
local preexec_function
for preexec_function in "${preexec_functions[@]}"; do

# Only execute each function if it actually exists.
# Test existence of function with: declare -[fF]
if type -t "$preexec_function" 1>/dev/null; then
$preexec_function "$this_command"
fi
done
}

# Execute this to set up preexec and precmd execution.
__bp_preexec_and_precmd_install() {

# Make sure this is bash that's running this and return otherwise.
if [[ -z "$BASH_VERSION" ]]; then
return 1;
fi

# Exit if we already have this installed.
if [[ "$PROMPT_COMMAND" == *"__bp_precmd_invoke_cmd"* ]]; then
return 1;
fi

# Adjust our HISTCONTROL Variable if needed.
__bp_adjust_histcontrol

# Take our existing prompt command and append a semicolon to it
# if it doesn't already have one.
local existing_prompt_command

if [[ -n "$PROMPT_COMMAND" ]]; then
existing_prompt_command=${PROMPT_COMMAND%${PROMPT_COMMAND##*[![:space:]]}}
existing_prompt_command=${existing_prompt_command%;}
existing_prompt_command=${existing_prompt_command/%/;}
else
existing_prompt_command=""
fi

# Finally install our traps.
PROMPT_COMMAND="__bp_precmd_invoke_cmd; ${existing_prompt_command} __bp_interactive_mode;"
trap '__bp_preexec_invoke_exec' DEBUG;

# Add two functions to our arrays for convenience
# of definition.
precmd_functions+=(precmd)
preexec_functions+=(preexec)
}

# Run our install so long as we're not delaying it.
if [[ -z "$__bp_delay_install" ]]; then
__bp_preexec_and_precmd_install
fi;

0 comments on commit dfddd34

Please sign in to comment.