README
ΒΆ
π lazyports
Visual TUI port manager for Mac and Linux. See what's listening, kill it with one key, tag it by project β all from a clean terminal UI. Inspired by lazygit and pgflow.
What it solves
You start a project on :3000, close your terminal, come back tomorrow, and
the port is still busy. Or you have three Next.js apps running and want to
kill the one on :3000 but not the one on :3030, and you don't remember
which PID belongs to which. lazyports answers in one terminal view:
π lazyports 16 listening β’ 6 tagged β’ view: flat β’ next: 8s
Active Ports (16) Port Details
ββ PORT PROCESS PID STATE CAT PROJECT ββ :3000 β LISTEN fe
β 3000 node 70595 LISTEN fe Amatista-eco β
β 3030 node 24986 LISTEN fe web β Project Amatista-eco
β 4000 node 69831 LISTEN fe Patines-eco β Process node
β 5000 ControlCe 676 LISTEN sys macOS-control β PID 70595
β 5173 node 70534 LISTEN fe Amatista-fe β User max
β 5432 postgres 2826 LISTEN db postgres β
β 6379 redis 2821 LISTEN cache redis β Tag frontend-patines β stale
β 11434 ollama 78424 LISTEN llm ollama β tag doesn't match project
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
x kill m mark X kill-marked t tag f filter c copy tab view r refresh q quit
Why lazyports
- One binary, zero runtime β pure Go with Bubble Tea, no Python, no Node, no language runtime to install.
- Auto-detects your projects β reads the process
cwdvialsofand infers which project each port belongs to. No config file, no manual tagging required. - Smart categorization β heuristically classifies each port as frontend, backend, database, cache, LLM, tunnel, dev tools, or system. With colors.
- Three view modes β
tabcycles: flat, by-category, by-project. - Multi-select + multi-kill β mark several ports, then kill them all in one go with a confirmation that lists every target.
- Stale tag detection β when your manual tag (e.g.
frontend-patines) no longer matches the auto-detected project (Amatista-eco), it shows aβ stalewarning so you can fix it. - Crisp performance β first scan warms the cache, every subsequent scan is ~130ms regardless of how many ports you have. Refresh interval adapts to scan speed.
- CLI mode β pipe ports as JSON to
jq, grep, or your editor.
Install
Quick install (one binary)
You need Go 1.22+ to build (only required once):
git clone https://github.com/ander0code/lazyports.git ~/projects/lazyports
cd ~/projects/lazyports
bash install.sh
install.sh will:
- Check for
go(complain if missing). go buildwith-ldflags="-s -w"(strips debug info β small binary).- Drop the binary at
~/.local/bin/lazyportsandchmod +xit. - Print a hint if
~/.local/binisn't on yourPATH.
If ~/.local/bin isn't on your PATH yet, add this to ~/.zshrc (or ~/.bashrc):
export PATH="$HOME/.local/bin:$PATH"
Then just:
lazyports
From source (no install)
git clone https://github.com/ander0code/lazyports.git
cd lazyports
go run . # launches the TUI
go run . --scan # quick text dump
Homebrew (planned)
brew install ander0code/tap/lazyports
Usage
lazyports # launch the TUI
lazyports --scan # print listening ports (text)
lazyports --scan --json # print as JSON for piping
lazyports --version
lazyports --help
Inside the TUI
| Key | Action |
|---|---|
β β / j k |
move cursor |
g / G |
jump to first / last |
x / enter |
kill selected port (or all marked if any) |
t |
tag / edit / remove project tag |
m |
mark / unmark for multi-kill |
M (shift) |
mark all visible (filtered) ports |
X (shift) |
open multi-kill modal showing every marked port |
f / / |
open filter input |
c |
copy kill -9 $(lsof -ti:PORT) to clipboard |
r |
force a refresh now |
tab |
cycle view: flat β by-category β by-project |
space |
in flat: mark; in grouped: collapse/expand group |
esc |
clear filter, or clear all marks |
q / ctrl+c |
quit |
Modal kill confirmation
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β β Kill port :3000 ? β
β β
β Process node β
β Project Amatista-eco β
β PID 70595 β
β β
β Sends SIGKILL to the listening process. β
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β y/enter yes, kill it n/esc cancel β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Multi-kill modal
When you've marked several ports with m:
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β Kill 4 marked ports ? β
β β
β fe :3000 node Amatista-eco frontend-β¦ β
β fe :3030 node web β
β fe :4000 node Patines-eco β
β fe :5173 node Amatista-fe frontend-β¦ β
β β
β Sends SIGKILL to 4 processes. β
β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β y/enter yes, kill them all n/esc cancel β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
CLI mode (no TUI)
For scripts and your editor:
$ lazyports --scan
8 listening ports
:3000 next-server pid=70595 max
:5432 postgres pid=2826 max
:6379 redis pid=2821 max
:8000 Python pid=3392 max
:5173 node pid=70534 max
:11434 ollama pid=78424 max
...
$ lazyports --scan --json | jq '.[] | select(.tag == "frontend")'
[
{ "port": 3000, "process": "node", "tag": "frontend", ... }
]
How it knows where each port comes from
For every listening port, lazyports:
- Runs
lsof -nP -iTCP -sTCP:LISTENto get the basics (port, PID, command, user). - Runs
lsof -d cwd -F pnonce to get every process'scwd(about 100ms on macOS, regardless of how many ports you have). - Walks the
cwdto extract the meaningful folder name β skippingnode_modules, version numbers (3.14.5), system paths (/System,/opt/homebrew,/Library), and umbrella folders. - For SSH tunnels, extracts the alias from the command line (
ssh -f -N jsoluciones-dbβ project isjsoluciones-db). - Caches the result per PID for 60s. Subsequent scans only re-run the cheap lsof call.
Categorization is heuristic but accurate for the common cases:
| Process / Command | Category |
|---|---|
next-server, vite, nuxt, webpack, astro, svelte, gatsby |
fe (frontend) |
express, fastify, nest, koa, flask, django, fastapi, uvicorn, gunicorn |
be (backend) |
postgres, mysqld, mariadbd, mongo, cockroach |
db (database) |
redis-server, memcached, keydb |
cache |
ollama, llama-server, vllm |
llm |
ssh -f -N <alias>, ssh -L β¦ |
tunnel |
adb, ngrok, cloudflared |
tools |
rapportd, ControlCe, Raycast, Spotify, Google Chrome, β¦ |
sys (system) |
| anything else | other |
Project tags (config)
Tags are stored in ~/.lazyports.json β a tiny file you can edit by hand:
{
"tags": {
"3000": "frontend",
"3030": "another-frontend",
"5432": "postgres-dev",
"6379": "redis-cache"
}
}
Set them in the TUI (t on a row) or edit the file directly. Reload the TUI
or press r to pick up file edits. The TAG column was removed from the
default view because it tends to go stale (you set frontend-patines for
the Patines project, then six months later that same port is running
Amatista-eco). Tags now only show in the Port Details panel, with a
β stale warning when they don't match the auto-detected project.
Performance
Measured on a Mac with 16 listening ports:
| Scenario | Time |
|---|---|
| First scan (cold cache) | ~440 ms |
| Subsequent scans (warm cache) | ~130 ms |
| Refresh interval | 8-20 s, adaptive |
| RAM | ~15 MB resident |
| Binary size | ~6 MB |
The auto-refresh is smart: it skips if a scan is already in flight (lsof hung, system load spike), uses an 80 ms spinner tick that self-cancels when nothing is happening, and only shows the spinner if a scan actually takes longer than 300 ms β so cached scans are invisible to the user.
Build for release
Cross-platform binaries via the Makefile:
make build # current platform
make build-all # mac/linux Γ amd64/arm64
ls dist/ # lazyports-darwin-arm64, lazyports-linux-amd64, β¦
Or with plain Go:
go build -ldflags="-s -w" -o lazyports .
Architecture
main.go # CLI flag parsing, tea.NewProgram
internal/
ports/
ports.go # lsof scanner with per-PID cache + bulk cwd fetch
killer.go # lsof -ti:PORT | kill -9
category.go # Categorize() + ProjectName() heuristics
*_test.go # 30+ unit tests
tags/
tags.go # ~/.lazyports.json with mutex
tui/
tui.go # Bubble Tea model, Update, View
commands.go # tea.Cmd factories (scan, kill, copy, tick)
messages.go # all message types
styles.go # lipgloss styles, palette
*test*.go # 24 unit tests
Why Bubble Tea: it's the framework lazygit-style tools use, it compiles
to a single static binary, and Lipgloss gives us crisp layout without ANSI
escape debugging. Why not Textual / Python: a single 6 MB binary is easier
to ship and run than asking users to pip install two packages.
Troubleshooting
"Permission denied" killing a port on macOS Some processes are owned by other users (system daemons, root). lazyports will tell you which PID refused; if it's yours but owned by root, run the binary as root:
sudo $(which lazyports)
macOS 12+ ports not showing
Grant your terminal (iTerm, Ghostty, β¦) Full Disk Access under
System Settings β Privacy & Security.
Clipboard copy doesn't work on Linux
lazyports tries wl-copy (Wayland), then xclip, then xsel. Install
one of them:
# Wayland
sudo apt install wl-clipboard
# X11
sudo apt install xclip
Tags not being read
Tags are loaded from ~/.lazyports.json on every scan. If you edit the
file by hand, press r to refresh.
Auto-detected project is wrong
Project name is derived from the process's cwd. If the process is
launched from a non-standard directory (e.g., ~/ or /tmp/), the
project name will be β. Run the process from its project folder for
better detection, or set a manual tag with t.
Contributing
Issues and PRs welcome. Keep the binary small, keep the dependencies minimal, keep the keys vim-like.
make test # run all tests
go vet ./... # static analysis
make build # build the binary
License
MIT β Β© 2026 ander0code
Documentation
ΒΆ
There is no documentation for this package.