Last active
June 23, 2026 20:12
-
-
Save lavoiesl/ed85c4cabca3dbc3dd2f0cbf1d9a3e1c to your computer and use it in GitHub Desktop.
Display running processes as a tree with smart path abbreviations
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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