concurrently

  1#!/usr/bin/env bash
  2#
  3# concurrently - Run multiple commands with multiplexed output
  4# Usage: concurrently [--color N] NAME1 'COMMAND1' [NAME2 'COMMAND2' ...]
  5#
  6# Runs all commands in parallel with output prefixed by [NAME].
  7# Press Ctrl+C to terminate all processes.
  8#
  9# Options:
 10#   --color N    Starting color offset (0-6), useful when running multiple
 11#                concurrent instances to differentiate output colors
 12#
 13
 14set -euo pipefail
 15
 16# Parse optional --color flag
 17COLOR_OFFSET=0
 18if [[ $# -ge 2 && "$1" == "--color" ]]; then
 19    if [[ "$2" =~ ^[0-9]+$ ]]; then
 20        COLOR_OFFSET="$2"
 21        shift 2
 22    else
 23        echo "Error: --color requires a non-negative integer" >&2
 24        exit 1
 25    fi
 26fi
 27
 28# Color palette for dark terminal themes
 29# Format: \e[38;2;R;G;Bm (256-color true color)
 30declare -a COLORS=(
 31    '\e[38;2;0;255;255m'    # Cyan
 32    '\e[38;2;0;255;0m'      # Green
 33    '\e[38;2;255;255;0m'    # Yellow
 34    '\e[38;2;255;0;255m'    # Magenta
 35    '\e[38;2;0;160;255m'    # Blue
 36    '\e[38;2;255;100;100m'  # Red (soft)
 37    '\e[38;2;255;165;0m'    # Orange
 38)
 39readonly RESET='\e[0m'
 40readonly NUM_COLORS=${#COLORS[@]}
 41
 42# Global exit code for trap
 43EXIT_CODE=0
 44
 45# Validate we have at least one name-command pair
 46if [[ $# -lt 2 ]]; then
 47    echo "Usage: concurrently [--color N] NAME1 'COMMAND1' [NAME2 'COMMAND2' ...]" >&2
 48    echo "  --color N    Starting color offset (0-6), useful when running multiple" >&2
 49    echo "               concurrent instances to differentiate output colors" >&2
 50    exit 1
 51fi
 52
 53# Validate we have an even number of arguments (name-cmd pairs)
 54if [[ $(( $# % 2 )) -ne 0 ]]; then
 55    echo "Error: Arguments must be name-command pairs" >&2
 56    exit 1
 57fi
 58
 59# Create temp directory for FIFOs
 60readonly TMPDIR=$(mktemp -d /tmp/concurrently.XXXXXX)
 61readonly FIFODIR="$TMPDIR/fifos"
 62readonly PIDDIR="$TMPDIR/pids"
 63mkdir -p "$FIFODIR" "$PIDDIR"
 64
 65# Track PIDs
 66declare -a JOB_PIDS=()
 67declare -a READER_PIDS=()
 68declare -a JOB_NAMES=()
 69
 70# Cleanup function
 71cleanup() {
 72    local exit_code=${1:-0}
 73
 74    # Kill all reader processes
 75    for pid in "${READER_PIDS[@]}"; do
 76        kill "$pid" 2>/dev/null || true
 77    done
 78
 79    # Kill all job processes
 80    for pid in "${JOB_PIDS[@]}"; do
 81        if kill -0 "$pid" 2>/dev/null; then
 82            kill -- -"$pid" 2>/dev/null || kill "$pid" 2>/dev/null || true
 83        fi
 84    done
 85
 86    # Remove temp directory
 87    rm -rf "$TMPDIR"
 88
 89    exit "$exit_code"
 90}
 91
 92trap 'cleanup $EXIT_CODE' INT TERM EXIT
 93
 94# Start reader process for a FIFO
 95# Reads lines from FIFO and prefixes with colored [NAME]
 96start_reader() {
 97    local name="$1"
 98    local fifo="$2"
 99    local color="$3"
100
101    (
102        while IFS= read -r line; do
103            printf "$color[%s]$RESET %s\n" "$name" "$line"
104        done < "$fifo"
105    ) &
106
107    READER_PIDS+=($!)
108}
109
110# Start all jobs
111job_index=0
112while [[ $# -gt 0 ]]; do
113    name="$1"
114    cmd="$2"
115    shift 2
116
117    # Validate name
118    if [[ "$name" == */* ]]; then
119        echo "Error: Name cannot contain '/': $name" >&2
120        EXIT_CODE=1
121        cleanup 1
122    fi
123
124    # Check for duplicates
125    for existing_name in "${JOB_NAMES[@]}"; do
126        if [[ "$existing_name" == "$name" ]]; then
127            echo "Error: Duplicate name: $name" >&2
128            EXIT_CODE=1
129            cleanup 1
130        fi
131    done
132
133    JOB_NAMES+=("$name")
134    fifo="$FIFODIR/$name"
135
136    # Create FIFO
137    mkfifo "$fifo"
138
139    # Assign color (modulo to cycle through palette, with optional offset)
140    color_index=$(((COLOR_OFFSET + job_index) % NUM_COLORS))
141    color="${COLORS[$color_index]}"
142
143    # Start reader for this FIFO with assigned color
144    start_reader "$name" "$fifo" "$color"
145
146    # Start job writing to FIFO (line buffered)
147    (
148        exec stdbuf -oL bash -c "$cmd" > "$fifo" 2>&1
149    ) &
150
151    JOB_PIDS+=($!)
152    job_index=$((job_index + 1))
153done
154
155# Wait for all jobs to exit
156for i in "${!JOB_NAMES[@]}"; do
157    name="${JOB_NAMES[$i]}"
158    pid="${JOB_PIDS[$i]}"
159
160    # Wait for this specific job
161    if kill -0 "$pid" 2>/dev/null; then
162        wait "$pid" 2>/dev/null || true
163        echo "[$name] (process exited)"
164    fi
165done
166
167# Give readers time to flush
168cleanup 0