March 18, 2026
I keep hitting the same tiny annoyance on my Mac: some dev server grabs a port, I forget where it came from, and then I burn five minutes doing the lsof | grep | awk | kill dance.
That annoyance became gatan: a small terminal UI that shows listening TCP processes, lets me inspect details, and kill a process safely.
I wanted this to feel quick and native in a terminal, so I built it in Bash rather than pulling in a larger runtime.
I set a few constraints early:
SIGTERM first, SIGKILL only if needed)The first big commit bootstrapped all of that in one go: app structure, tests, CI linting, and release automation.
At the center of gatan is a simple idea:
lsof to collect listening TCP processes.One tiny detail that mattered more than expected: command names from lsof can be truncated by default. I hit that early and fixed it by asking for full command names with +c 0.
core_raw_lsof() {
# `+c 0` asks lsof for full command names (no default 9-char truncation).
sudo lsof +c 0 -nP -iTCP -sTCP:LISTEN 2>/dev/null
}That one flag made the UI materially more useful.
One of the least glamorous bugs was also one of the most important: macOS system Bash (3.2) does not support fractional read timeouts the way newer Bash versions do.
If you build keyboard handling around sub-second timeouts and forget that, input starts behaving weirdly on default macOS setups.
So I added a compatibility layer to coerce fractional timeouts when running on older Bash.
ui_read_timeout() {
local timeout="${1:-1}"
# macOS system bash 3.2 only accepts integer read timeouts.
if [ "${BASH_VERSINFO[0]:-0}" -lt 4 ] && [[ "$timeout" == *.* ]]; then
printf '1\n'
return 0
fi
printf '%s\n' "$timeout"
}Not exciting, but exactly the sort of thing that makes CLI tools feel either solid or flaky.
After the first version worked, the next chunk of effort was all about responsiveness and layout polish.
I iterated through:
A meaningful change here was shifting refresh work into a background job and polling completion, rather than blocking the interface.
app_start_main_refresh() {
APP_MAIN_REFRESH_JOB_FILE="$(mktemp "${TMPDIR:-/tmp}/gatan-main-refresh.XXXXXX")" || return 1
(
core_collect_sorted_listeners >"$APP_MAIN_REFRESH_JOB_FILE" 2>/dev/null || true
) &
APP_MAIN_REFRESH_JOB_PID=$!
}That made navigation feel much less sticky when the system was busy.
The inspect view started basic, then grew into something much more practical:
At the same time, I was opinionated about process termination:
SIGTERMSIGKILLThis gives a safer default without hiding the “nuke it” option when needed.
I also added a dedicated sudo explainer flow so users understand why elevation is needed and what gatan is doing with it.
Even though this is a small utility, I wanted it to ship like a real product:
shellcheck + shfmt in CIThat paid off quickly: I shipped 0.2.0 and 0.3.0 within the first wave of changes, with release notes generated from actual commit history.
Today I also revisited docs and added screenshot coverage to the README.
Interesting side note: I first tried automating screenshot capture directly from a controlled terminal session, but that path became fiddly fast (TTY edge-cases, environment parity, command availability). It was genuinely faster and cleaner to take the screenshots manually and wire them in.
That was a nice reminder: automation is great, but only when the setup cost is lower than just doing the work.
If I keep iterating on gatan, I’ll probably tackle:
I like projects like this because they’re small enough to finish but deep enough to force good decisions.
gatan started as “I’m tired of checking ports manually,” but it turned into a neat little case study in Bash ergonomics, terminal UX, and release discipline.
If you want to try it, you can install it with:
bpkg install -g lucianbuzzo/gatanSource code: https://github.com/LucianBuzzo/gatan