Skip to content

Instantly share code, notes, and snippets.

@lavoiesl
Last active June 23, 2026 20:12
Show Gist options
  • Select an option

  • Save lavoiesl/ed85c4cabca3dbc3dd2f0cbf1d9a3e1c to your computer and use it in GitHub Desktop.

Select an option

Save lavoiesl/ed85c4cabca3dbc3dd2f0cbf1d9a3e1c to your computer and use it in GitHub Desktop.
Display running processes as a tree with smart path abbreviations
#!/usr/bin/env ruby
# frozen_string_literal: true
require "optparse"
$app_name = File.basename($PROGRAM_NAME)
options = {
filter: nil,
root: nil,
descendants: false,
user: nil,
group: nil,
full_path: false,
smart_path: true,
show_args: false,
show_env: false,
show_user: false,
dedup: false,
include_self: false,
}
OptionParser.new do |opts|
opts.banner = <<~BANNER
Usage: #{$app_name} [options]
Display running processes as a tree with smart path abbreviations.
Filters, groups, and deduplicates processes to help you quickly understand
what's running and how processes relate to each other.
BANNER
opts.on("-f", "--filter PATTERN", "Filter processes by name (regex, like pgrep -f)") do |v|
options[:filter] = Regexp.new(v, Regexp::IGNORECASE)
end
opts.on("-r", "--root PID", Integer, "Clamp tree to descendants of PID") do |v|
options[:root] = v
end
opts.on("-d", "--[no-]descendants", "Show all descendants of matching processes") do |v|
options[:descendants] = v
end
opts.on("-u", "--user [USER]", "Filter by process owner (defaults to current user)") do |v|
options[:user] = v || ENV["USER"]
end
opts.on("-g", "--group [GID]", "Filter by process group (defaults to current group)") do |v|
options[:group] = v || Process.gid.to_s
end
opts.on("-p", "--[no-]full-path", "Show full process path") do |v|
options[:full_path] = v
end
opts.on("--[no-]smart-path", "Show smart abbreviated path (default: on)") do |v|
options[:smart_path] = v
end
opts.on("-a", "--[no-]args", "Show process arguments") do |v|
options[:show_args] = v
end
opts.on("-e", "--[no-]env", "Show process environment variables") do |v|
options[:show_env] = v
end
opts.on("-w", "--[no-]show-user", "Show process owner") do |v|
options[:show_user] = v
end
opts.on("-x", "--[no-]dedup", "Deduplicate identical sibling subtrees (show count instead)") do |v|
options[:dedup] = v
end
opts.on("--[no-]include-self", "Include this script's own process in the output") do |v|
options[:include_self] = v
end
opts.on("-h", "--help", "Show this help") do
puts opts
puts <<~EXAMPLES
Examples:
# Show all processes owned by current user
$ #{$app_name} -u
# Find Docker-related processes and their children
$ #{$app_name} -f docker -d
1 /sbin/launchd
└── 21361 /Applications/Docker.app/.../com.docker.backend
└── 21427 /Applications/Docker.app/.../Docker Desktop
├── 18674 /Applications/Docker.app/.../Docker Desktop Helper
└── 21535 /Applications/Docker.app/.../Docker Desktop Helper
# Show all Chrome helpers, deduplicated
$ #{$app_name} -f chrome -d -x
1 /sbin/launchd
└── 5012 /Applications/Google Chrome.app/.../Google Chrome
├── (12×) /Applications/Google Chrome.app/.../Google Chrome Helper (Renderer)
└── (3×) /Applications/Google Chrome.app/.../Google Chrome Helper (GPU)
# Show full paths instead of smart abbreviations
$ #{$app_name} -f node -p
1 /sbin/launchd
└── 8821 /opt/homebrew/Cellar/node/22.0.0/bin/node
# Same, with smart path (default)
$ #{$app_name} -f node
1 /sbin/launchd
└── 8821 node
EXAMPLES
exit
end
end.parse!
# --- Process Discovery ---
ProcInfo = Struct.new(:pid, :ppid, :user, :gid, :comm, :full_path, :args, keyword_init: true)
def read_processes
# Use -Aww for all processes with wide output to avoid truncation.
# We use `lstart` as a fixed-width delimiter (24 chars like "Mon Jun 23 09:00:00 2025")
# to reliably split between structured fields and free-form `comm`/`args`.
# Format: PID PPID USER GID COMM (NUL separator) ARGS
# Actually, we'll query comm and args separately per-pid for accuracy.
#
# Strategy: get structured fields + args in one pass, then use `comm` from a separate
# ps call to get the un-truncated executable basename.
# But `ps -o comm=` truncates on macOS... so instead we use `args` and match against
# the macOS .app bundle pattern to extract the real name.
lines = `ps -Aww -o pid=,ppid=,user=,gid=,args=`.lines
lines.filter_map do |line|
parts = line.strip.split(/\s+/, 5)
next if parts.size < 5
pid = parts[0].to_i
ppid = parts[1].to_i
user = parts[2]
gid = parts[3]
args = parts[4]
# Extract the executable path from args.
# The command may contain spaces (e.g. "Kiro Helper (Plugin)").
# Heuristic: if args starts with a path, consume until we hit a token
# that looks like an argument (starts with -) or we run out.
full_path = extract_executable_path(args)
comm = derive_comm_name(full_path)
ProcInfo.new(
pid: pid,
ppid: ppid,
user: user,
gid: gid,
comm: comm,
full_path: full_path,
args: args
)
end
end
# Extract the executable path from the args string.
# Handles paths with spaces (e.g. macOS .app bundles).
def extract_executable_path(args)
if args.start_with?("/")
# Strategy: look for known macOS bundle pattern first
# Pattern: .../Contents/MacOS/<executable name> followed by space+dash-arg or EOL
if args =~ %r{\A(/.*?/Contents/MacOS/[^\n]+?)(\s+-|$)}
candidate = $1.rstrip
return candidate if file_exists?(candidate)
end
# Try progressively longer path prefixes by checking filesystem
tokens = args.split(/\s+/)
path = ""
tokens.each_with_index do |token, i|
path = i == 0 ? token : "#{path} #{token}"
return path if file_exists?(path)
# Stop early if this clearly isn't going to resolve
break if i > 10
end
# Fallback: match up to Contents/MacOS/<name>
if args =~ %r{\A(/.*?/Contents/MacOS/[^\s]+)}
return $1
end
end
# Final fallback: first whitespace-delimited token
args.split(/\s+/, 2).first
end
def file_exists?(path)
File.file?(path)
rescue Errno::EPERM, Errno::EACCES
false
end
# Derive a short display name from the full executable path.
def derive_comm_name(full_path)
# For macOS .app bundles: extract the binary name after Contents/MacOS/
if full_path =~ %r{/Contents/MacOS/(.+)$}
return $1
end
File.basename(full_path)
end
# Produce a smart abbreviated path that preserves meaningful context.
# Rules applied in order:
# 1. Nix store paths → strip hash prefix
# 2. Homebrew Cellar/keg paths → pkg/exe or just exe
# 3. .app bundles (with XPC service awareness) → App.app/.../Binary
# 4. .framework bundles → Framework.framework/.../Binary
# 5. User-relative paths → ~/.../exe
# 6. Everything else → full path unchanged
def smart_path(full_path)
# Nix store: /nix/store/<hash>-<pkg>-<version>/bin/<exe>
if full_path =~ %r{\A/nix/store/[a-z0-9]{32}-([^/]+)/(.+)\z}
pkg_with_version = $1
remainder = File.basename($2)
# Strip trailing version from package name (e.g. "nodejs-22.0.0" → "nodejs")
pkg = pkg_with_version.sub(/-\d[\d.]*\z/, "")
if remainder == pkg || remainder.start_with?(pkg) || pkg.start_with?(remainder)
return remainder
end
return "#{pkg}/#{remainder}"
end
# Homebrew Cellar: /opt/homebrew/Cellar/<pkg>/<version>/bin/<exe>
# Also handles: /usr/local/Cellar/...
if full_path =~ %r{\A/(?:opt/homebrew|usr/local)/Cellar/([^/]+)/[^/]+/(.+)\z}
pkg = $1
remainder = File.basename($2)
# If the binary name is closely related to the package name, just show the binary.
# "closely related" = one is a prefix of the other (handles node/node, postgresql/postgres, etc.)
pkg_base = pkg.sub(/@.*/, "")
if remainder == pkg_base || remainder.start_with?(pkg_base) || pkg_base.start_with?(remainder)
return remainder
end
return "#{pkg}/#{remainder}"
end
# .app bundle
if full_path =~ %r{\A(.+?/([^/]+)\.app)/}
app_path = $1
app_name = $2
basename = File.basename(full_path)
# XPC services: ...App.app/.../SomeService.xpc/.../Binary
# Show as: App.app/.../ServiceName (using XPC bundle name)
if full_path =~ %r{/([^/]+)\.xpc/}
xpc_name = $1
if basename == xpc_name
return "#{app_path}/.../#{xpc_name}" if xpc_name != app_name
return app_path
else
return "#{app_path}/.../#{basename}"
end
end
# If basename matches the app name, no need to repeat it
if basename == app_name
return app_path
else
return "#{app_path}/.../#{basename}"
end
end
# .framework bundle (strip /Versions/A/ or /Versions/Current/ noise)
if full_path =~ %r{\A(.+?/([^/]+)\.framework)/}
framework_path = $1
framework_name = $2
basename = File.basename(full_path)
if basename == framework_name
return framework_path
else
return "#{framework_path}/.../#{basename}"
end
end
# User-relative paths: replace $HOME with ~
home = ENV["HOME"]
if home && full_path.start_with?("#{home}/")
relative = full_path[(home.length + 1)..]
# If the relative path is short enough, just show ~/...
if relative.count("/") <= 2
return "~/#{relative}"
else
return "~/.../#{File.basename(full_path)}"
end
end
full_path
end
def read_env(pid)
# macOS: use ps -Eww to get environment
output = `ps -Eww -o args= -p #{pid} 2>/dev/null`.strip
return {} if output.empty?
# Environment variables appear after the command+args, separated by spaces
# They look like KEY=VALUE. We try to extract them from the end.
env = {}
# On macOS, `ps -E` appends env vars after the command args
# We'll use /proc on Linux or ps environ on macOS
env_str = `ps -p #{pid} -o environ= 2>/dev/null`.strip
if env_str.empty?
# macOS fallback: parse from ps -Eww output
# env vars are appended after args, each is KEY=VALUE
parts = output.split(/\s+/)
parts.each do |part|
if part.match?(/\A[A-Z_][A-Z0-9_]*=/)
k, v = part.split("=", 2)
env[k] = v
end
end
else
env_str.split("\0").each do |entry|
k, v = entry.split("=", 2)
env[k] = v if k && v
end
end
env
rescue
{}
end
processes = read_processes
# Build lookup tables
by_pid = processes.each_with_object({}) { |p, h| h[p.pid] = p }
children_of = Hash.new { |h, k| h[k] = [] }
processes.each { |p| children_of[p.ppid] << p.pid }
# --- Filtering ---
matching_pids = Set.new
if options[:filter] || options[:user] || options[:group]
processes.each do |p|
filter_match = options[:filter] ? options[:filter].match?(p.args) : true
user_match = options[:user] ? p.user == options[:user] : true
group_match = options[:group] ? p.gid == options[:group] : true
matching_pids << p.pid if filter_match && user_match && group_match
end
else
matching_pids = Set.new(processes.map(&:pid))
end
# If root is set, restrict to that subtree
if options[:root]
subtree = Set.new
queue = [options[:root]]
while (pid = queue.shift)
subtree << pid
queue.concat(children_of[pid])
end
matching_pids &= subtree
end
# If --descendants, add all descendants of matching PIDs
if options[:descendants]
expanded = Set.new
matching_pids.each do |pid|
queue = [pid]
while (p = queue.shift)
expanded << p
queue.concat(children_of[p])
end
end
matching_pids = expanded
end
# Include ancestors up to root so the tree stays connected
visible = Set.new(matching_pids)
matching_pids.each do |pid|
current = pid
while current && current > 0
break if visible.include?(current) && current != pid
visible << current
break if options[:root] && current == options[:root]
current = by_pid[current]&.ppid
end
end
# Exclude this script's own process unless --include-self is passed
unless options[:include_self]
visible.delete(Process.pid)
end
# --- Tree Building with Deduplication ---
TreeNode = Struct.new(:label, :pids, :children, keyword_init: true)
def format_label(proc_info, options)
parts = []
# PID (always shown for single, count for deduped — handled at render time)
# This returns the base label; PID/count is prepended during render
if options[:show_user]
parts << "@#{proc_info.user}"
end
if options[:show_env]
env = read_env(proc_info.pid)
env.each { |k, v| parts << "#{k}=#{v}" } unless env.empty?
end
if options[:full_path]
parts << proc_info.full_path
else
parts << proc_info.comm
end
if options[:show_args]
stripped = proc_info.args
if stripped.start_with?(proc_info.full_path)
stripped = stripped[proc_info.full_path.length..].lstrip
else
stripped = stripped.sub(/\A\S+\s*/, "")
end
parts << stripped unless stripped.empty?
end
parts.join(" ")
end
def dedup_key(proc_info, options)
# For deduplication, we compare by the display-relevant fields (minus PID-specific env)
parts = []
parts << proc_info.user if options[:show_user]
if options[:full_path]
parts << proc_info.full_path
elsif options[:smart_path]
parts << smart_path(proc_info.full_path)
else
parts << proc_info.comm
end
if options[:show_args]
stripped = proc_info.args
if stripped.start_with?(proc_info.full_path)
stripped = stripped[proc_info.full_path.length..].lstrip
else
stripped = stripped.sub(/\A\S+\s*/, "")
end
parts << stripped
end
parts.join("|")
end
def build_tree(root_pid, children_of, by_pid, visible, options)
return nil unless visible.include?(root_pid)
proc_info = by_pid[root_pid]
return nil unless proc_info
child_nodes = children_of[root_pid]
.select { |cpid| visible.include?(cpid) }
.filter_map { |cpid| build_tree(cpid, children_of, by_pid, visible, options) }
deduped_children = options[:dedup] ? dedup_siblings(child_nodes, options) : child_nodes
TreeNode.new(
label: dedup_key(proc_info, options),
pids: [root_pid],
children: deduped_children
)
end
def tree_signature(node)
child_sigs = node.children.map { |c| tree_signature(c) }.sort
"#{node.label}(#{child_sigs.join(",")})"
end
def dedup_siblings(nodes, _options)
groups = {}
nodes.each do |node|
sig = tree_signature(node)
if groups[sig]
groups[sig].pids.concat(node.pids)
else
groups[sig] = node.dup
groups[sig].pids = node.pids.dup
end
end
groups.values
end
# --- Rendering ---
def render_label(node, by_pid, options)
count = node.pids.size
proc_info = by_pid[node.pids.first]
parts = []
if count > 1
parts << "(#{count}×)"
else
parts << node.pids.first.to_s
end
if options[:show_user]
parts << "@#{proc_info.user}"
end
if options[:show_env] && count == 1
env = read_env(proc_info.pid)
env.each { |k, v| parts << "#{k}=#{v}" } unless env.empty?
end
if options[:full_path]
parts << proc_info.full_path
elsif options[:smart_path]
parts << smart_path(proc_info.full_path)
else
parts << proc_info.comm
end
if options[:show_args]
stripped = proc_info.args
if stripped.start_with?(proc_info.full_path)
stripped = stripped[proc_info.full_path.length..].lstrip
else
stripped = stripped.sub(/\A\S+\s*/, "")
end
parts << stripped unless stripped.empty?
end
parts.join(" ")
end
def render_tree(node, by_pid, options, prefix = "", is_last = true, is_root = true)
label = render_label(node, by_pid, options)
if is_root
puts label
else
connector = is_last ? "└── " : "├── "
puts "#{prefix}#{connector}#{label}"
end
child_prefix = if is_root
""
else
prefix + (is_last ? " " : "│ ")
end
node.children.each_with_index do |child, i|
child_is_last = (i == node.children.size - 1)
render_tree(child, by_pid, options, child_prefix, child_is_last, false)
end
end
# --- Main ---
root_pids = if options[:root]
[options[:root]].select { |pid| visible.include?(pid) }
else
visible.select { |pid| !visible.include?(by_pid[pid]&.ppid) }
end
if root_pids.empty?
$stderr.puts "No matching processes found."
exit 1
end
root_pids.sort.each do |root_pid|
tree = build_tree(root_pid, children_of, by_pid, visible, options)
render_tree(tree, by_pid, options) if tree
end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment