I made a CLI tool called ports-cli

commandline interface toolports-clicli
2026-01-14

Why I made it

When developing, I often find myself asking "what's using this port?" Especially at work, I usually have multiple repo servers running at the same time. Sure, lsof -i :3000 works, but the output is too verbose and I got tired of remembering the command every time.

So I made ports-cli. A CLI tool that shows listening ports in a clean format, along with process paths.

Features

  ports              # Show all listening ports

  ports -u           # Show only my processes

  ports bye 3000     # Kill the process on port 3000

The output of ports -u looks like this:

  PORT     PROCESS                  PATH
  ----     -------                  ----
  3000     my-app                   ~/Documents/GitHub/my-app
  5432     postgres                 -
  8080     test-server              ~/Documents/GitHub/test-server

What I wanted to see was: current open ports, app names, and their paths.

For Node processes, it reads the project name from package.json. Instead of just showing "node", it displays the actual project name, which I find much more intuitive.


Here's the development process by version:

v1.0.0 - Basic functionality

It started simple. Get listening ports via lsof, and if it's a Node process, parse the name from package.json.

  # Get project name for Node processes
  if [ "$proc" = "node" ]; then
    cwd=$(lsof -p "$pid" 2>/dev/null | grep cwd | awk '{print $9}')
    if [ -f "$cwd/package.json" ]; then
      name=$(grep '"name"' "$cwd/package.json" | head -1 | sed 's/.*: *"\([^"]*\)".*/\1/')
      if [ -n "$name" ]; then
        proc="$name"
      fi
    fi
  fi

v1.0.1 - Added -u option

But as I used it, a problem emerged. System processes like postgres, redis, and AirPlay were all showing up, making it hard to find my dev servers.

So I added the -u (--user) option. It filters to show only processes the user directly started. Simply put, processes that have a path. System processes show - for PATH, so filtering by this lets me see only the Node processes I started.

  if [ "$USER_ONLY" = true ]; then
    [ "$display_path" = "-" ] && continue
    [ -z "$display_path" ] && continue
  fi

I struggled a bit here. I was tracking duplicate ports with declare -A seen_ports inside a while read loop, but it wasn't working...

The cause was a subshell issue with pipelines. When you do lsof | while read, the while loop runs in a subshell and variables don't get passed to the parent shell.

The solution was process substitution:


  # This doesn't work (subshell issue)
  lsof -iTCP -sTCP:LISTEN | while read line; do
    ...
  done

  # This is the way
  while read line; do
    ...
  done < <(lsof -iTCP -sTCP:LISTEN)

v1.2.0 - Added ports bye command

I also wanted to kill ports instantly. I got tired of copying PIDs and typing kill -9 every time, so I added the ports bye <port> command. Bye~

  kill_port() {
    local port=$1
    local pid=$(lsof -ti TCP:$port -sTCP:LISTEN 2>/dev/null)

    if [ -z "$pid" ]; then
      echo "${RED}No process found on port $port${RESET}"
      exit 1
    fi

    kill -9 $pid 2>/dev/null

    if [ $? -eq 0 ]; then
      echo "${GREEN}✓ Killed $proc_name (PID: $pid) on port $port${RESET}"
    fi
  }

Another bug... macOS's default bash is version 3.x, which doesn't support declare -A (associative arrays). Since I wanted to distribute via Homebrew, it needed to work with the default bash, so I changed it to string-based.

  # Only works on bash 4.x+
  declare -A seen_ports
  seen_ports[$port]=1

  # bash 3.x compatible
  seen_ports=""
  if echo "$seen_ports" | grep -q ":${port}:"; then
    continue
  fi
  seen_ports="${seen_ports}:${port}:"

Since I made it, I wanted to distribute it via Homebrew. I'd never done it before, and since I made the project at work, I wanted an easy way to install it at home. Really simple reasons... but anyway...

  1. Create homebrew-tap repo

Create a homebrew-tap repo on GitHub and write the Formula/ports-cli.rb file:

  class PortsCli < Formula
    desc "Show listening ports with process names and paths"
    homepage "https://github.com/givvemee/ports-cli"
    url "https://github.com/givvemee/ports-cli/archive/refs/tags/v1.2.0.tar.gz"
    sha256 "3fd0635de73b07fb87d6bdd533312e9b3fa2ab3ed286dacf2be58cf3c27d2e86"
    license "MIT"

    def install
      bin.install "bin/ports"
    end

    test do
      system "#{bin}/ports", "--version"
    end
  end
  1. Create release tag
  git tag v1.2.0
  git push origin v1.2.0
  1. Get sha256 hash
  curl -sL https://github.com/givvemee/ports-cli/archive/refs/tags/v1.2.0.tar.gz | shasum -a 256

This is a bit tedious since you need to get a new hash and update the Formula every time you create a tag. But since there probably won't be many changes, I can live with a little inconvenience.

  1. Install
  brew tap givvemee/tap
  brew install ports-cli

Done! It's nothing fancy, but I'm using it well.