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