Group the list of insrtalled packages in 2-level groups

Here is a script, that after you run, the following command in a sub dir.
pacman -Qq > packages.txt
You can run the following bash scriopt for some formmating pizazzs:

chmod +x group-packages.sh

./group-packages.sh --version
./group-packages.sh --help

# one argument β†’ packages.md
./group-packages.sh packages.txt

# dry-run
./group-packages.sh -n packages.txt

# quiet + explicit output
./group-packages.sh -q packages.txt report.md

Here is the script:

#!/usr/bin/env bash
# =============================================================================
# group-packages.sh
# =============================================================================
# Description : Group Arch / EndeavourOS package lists into a 2-level hierarchy
#               based on common prefixes (lib32-, python-, kde, plasma-, etc.)
#
# Version     : 1.3.0
# Author      : Generated for package-list organization
# Created     : 2026-07-29
# Last modified: 2026-07-29 19:44 CDT
# License     : MIT
# Shell       : bash 4+
# Dependencies: coreutils (mktemp, sort, wc, tr, basename, dirname)
#
# Changelog:
#   1.3.0  - Fixed path comparison & one-arg output derivation
#            Added --dry-run, --quiet, empty-file warning, bash version check
#            Deduplicate input, safer abs-path helper, improved summary
#   1.2.0  - One-arg mode: packages.txt β†’ packages.md
#            Safety: never overwrite the input file
#   1.1.0  - Added --help, --version, meta header, better argument handling
#   1.0.0  - Initial release with 2-level prefix grouping
# =============================================================================

set -euo pipefail

# -----------------------------------------------------------------------------
# Bash version guard
# -----------------------------------------------------------------------------
if (( BASH_VERSINFO[0] < 4 )); then
  echo "Error: bash 4.0 or newer is required (found ${BASH_VERSINFO[0]}.${BASH_VERSINFO[1]})" >&2
  exit 1
fi

# -----------------------------------------------------------------------------
# Meta / version constants
# -----------------------------------------------------------------------------
readonly SCRIPT_NAME="group-packages.sh"
readonly SCRIPT_VERSION="1.3.0"
readonly SCRIPT_DATE="2026-07-29"
readonly SCRIPT_LAST_MODIFIED="2026-07-29 19:44 CDT"
readonly SCRIPT_DESCRIPTION="Group Arch/EndeavourOS packages by common prefix (2 levels)"

# -----------------------------------------------------------------------------
# Defaults
# -----------------------------------------------------------------------------
DEFAULT_INPUT="/home/workdir/attachments/packages.txt"
DEFAULT_OUTPUT="packages_grouped.md"

INPUT=""
OUTPUT=""
SHOW_HELP=0
SHOW_VERSION=0
DRY_RUN=0
QUIET=0

# -----------------------------------------------------------------------------
# Helpers
# -----------------------------------------------------------------------------
abs_path() {
  local p="$1"
  if command -v realpath >/dev/null 2>&1; then
    realpath -m -- "$p" 2>/dev/null && return
  fi
  if command -v readlink >/dev/null 2>&1; then
    readlink -f -- "$p" 2>/dev/null && return
  fi
  # pure-bash fallback
  if [[ "$p" == /* ]]; then
    printf '%s\n' "$p"
  else
    printf '%s/%s\n' "$(pwd -P)" "$p"
  fi
}

derive_md_name() {
  local infile="$1"
  local dir base name
  dir=$(dirname -- "$infile")
  base=$(basename -- "$infile")

  case "$base" in
    *.txt|*.list|*.pkg|*.packages|*.lst)
      name="${base%.*}"
      ;;
    *)
      name="${base%.*}"
      [[ "$name" == "$base" ]] && name="$base"   # no extension at all
      ;;
  esac
  [[ -z "$name" ]] && name="packages"

  if [[ "$dir" == "." ]]; then
    printf '%s.md\n' "$name"
  else
    printf '%s/%s.md\n' "$dir" "$name"
  fi
}

# -----------------------------------------------------------------------------
# Usage / help
# -----------------------------------------------------------------------------
usage() {
  cat << EOF
$SCRIPT_NAME – $SCRIPT_DESCRIPTION

Usage:
  $SCRIPT_NAME [OPTIONS] [INPUT_FILE] [OUTPUT_FILE]

Arguments:
  INPUT_FILE    Path to plain-text package list (one package per line)
                Default: $DEFAULT_INPUT
  OUTPUT_FILE   Path for the generated Markdown report
                Default (0 args) : $DEFAULT_OUTPUT
                Default (1 arg)  : <basename of INPUT>.md

Options:
  -h, --help       Show this help message and exit
  -v, --version    Show version and meta information and exit
  -n, --dry-run    Show what would be done; do not write the output file
  -q, --quiet      Suppress the β€œGroups created” summary

Examples:
  $SCRIPT_NAME                          # uses defaults
  $SCRIPT_NAME packages.txt             # β†’ packages.md
  $SCRIPT_NAME packages.txt out.md      # explicit output
  $SCRIPT_NAME -n packages.txt          # dry-run
  $SCRIPT_NAME --version
  $SCRIPT_NAME -h

Safety:
  The script will NEVER overwrite the input file.
  If the computed/requested output path equals the input path,
  it aborts with an error.

Exit codes:
  0  Success
  1  Error (missing input, overwrite protection, invalid option, etc.)
  2  No packages processed (empty input after deduplication)
EOF
}

version_info() {
  cat << EOF
$SCRIPT_NAME version $SCRIPT_VERSION
Description   : $SCRIPT_DESCRIPTION
Created       : $SCRIPT_DATE
Last modified : $SCRIPT_LAST_MODIFIED
License       : MIT
Shell         : bash 4+
EOF
}

# -----------------------------------------------------------------------------
# Argument parsing
# -----------------------------------------------------------------------------
POSITIONAL=()

while [[ $# -gt 0 ]]; do
  case "$1" in
    -h|--help)
      SHOW_HELP=1
      shift
      ;;
    -v|--version)
      SHOW_VERSION=1
      shift
      ;;
    -n|--dry-run)
      DRY_RUN=1
      shift
      ;;
    -q|--quiet)
      QUIET=1
      shift
      ;;
    --)
      shift
      POSITIONAL+=("$@")
      break
      ;;
    -*)
      echo "Error: unknown option '$1'" >&2
      echo "Try '$SCRIPT_NAME --help' for more information." >&2
      exit 1
      ;;
    *)
      POSITIONAL+=("$1")
      shift
      ;;
  esac
done

if [[ $SHOW_HELP -eq 1 ]]; then
  usage
  exit 0
fi

if [[ $SHOW_VERSION -eq 1 ]]; then
  version_info
  exit 0
fi

# -----------------------------------------------------------------------------
# Resolve INPUT / OUTPUT from positional arguments
# -----------------------------------------------------------------------------
case ${#POSITIONAL[@]} in
  0)
    INPUT="$DEFAULT_INPUT"
    OUTPUT="$DEFAULT_OUTPUT"
    ;;
  1)
    INPUT="${POSITIONAL[0]}"
    OUTPUT=$(derive_md_name "$INPUT")
    ;;
  2)
    INPUT="${POSITIONAL[0]}"
    OUTPUT="${POSITIONAL[1]}"
    ;;
  *)
    echo "Error: too many arguments" >&2
    echo "Try '$SCRIPT_NAME --help' for more information." >&2
    exit 1
    ;;
esac

# -----------------------------------------------------------------------------
# Safety: never overwrite the input file
# -----------------------------------------------------------------------------
abs_input=$(abs_path "$INPUT")
abs_output=$(abs_path "$OUTPUT")

if [[ "$abs_input" == "$abs_output" ]]; then
  echo "Error: output path would overwrite the input file:" >&2
  echo "  input : $INPUT" >&2
  echo "  output: $OUTPUT" >&2
  echo "Choose a different output name." >&2
  exit 1
fi

# -----------------------------------------------------------------------------
# Input validation
# -----------------------------------------------------------------------------
if [[ ! -f "$INPUT" ]]; then
  echo "Error: input file '$INPUT' not found" >&2
  exit 1
fi

if [[ ! -s "$INPUT" ]]; then
  echo "Warning: input file is empty – nothing to group." >&2
fi

# -----------------------------------------------------------------------------
# Temporary workspace
# -----------------------------------------------------------------------------
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT

# -----------------------------------------------------------------------------
# Helper: assign a package to a group file
# -----------------------------------------------------------------------------
assign() {
  local pkg="$1"
  local group="$2"
  echo "$pkg" >> "$TMPDIR/$group"
}

# -----------------------------------------------------------------------------
# Classification rules (order matters – more specific first)
# -----------------------------------------------------------------------------
declare -A SEEN=()          # for de-duplication
PACKAGE_COUNT=0

while IFS= read -r pkg || [[ -n "$pkg" ]]; do
  # trim whitespace
  pkg="${pkg#"${pkg%%[![:space:]]*}"}"
  pkg="${pkg%"${pkg##*[![:space:]]}"}"
  [[ -z "$pkg" ]] && continue

  # skip duplicates
  [[ -n ${SEEN["$pkg"]+x} ]] && continue
  SEEN["$pkg"]=1
  ((PACKAGE_COUNT++)) || true

  case "$pkg" in
    # --- Kernel / firmware / core ---
    linux-api-headers|linux-headers|linux-lts|linux-xanmod-x64v2|linux-xanmod-x64v2-headers)
      assign "$pkg" "linux-kernel" ;;
    linux-firmware*)
      assign "$pkg" "linux-firmware" ;;
    systemd|systemd-libs|systemd-resolvconf|systemd-sysvcompat|systemdgenie)
      assign "$pkg" "systemd" ;;
    dbus|dbus-broker|dbus-broker-units|dbus-units)
      assign "$pkg" "dbus" ;;

    # --- Audio / Bluetooth / Network / Printing ---
    alsa-*)
      assign "$pkg" "alsa" ;;
    pipewire*)
      assign "$pkg" "pipewire" ;;
    bluez*)
      assign "$pkg" "bluez" ;;
    networkmanager*)
      assign "$pkg" "networkmanager" ;;
    cups*)
      assign "$pkg" "cups" ;;

    # --- Desktop (KDE / Plasma / themes) ---
    plasma-*)
      assign "$pkg" "plasma" ;;
    breeze*)
      assign "$pkg" "breeze" ;;
    oxygen*)
      assign "$pkg" "oxygen" ;;
    adwaita-*)
      assign "$pkg" "adwaita" ;;
    sddm*)
      assign "$pkg" "sddm" ;;

    # KDE frameworks + apps (broad catch)
    kaccounts*|kactivity*|karchive*|kauth*|kbookmarks*|kcalc*|kcalendar*| \
    kcharselect*|kclock*|kcmutils*|kcodecs*|kcolor*|kcompletion*|kconfig*| \
    kcontacts*|kcoreaddons*|kcrash*|kdbusaddons*|kde-*|kdebug*|kdeclarative*| \
    kdeconnect*|kdecoration*|kded*|kdegraphics*|kdenetwork*|kdenlive*| \
    kdeplasma*|kdesu*|kdf*|kdialog*|kdnssd*|kdsingle*|kdsoap*|kfilemetadata*| \
    kfind*|kgamma*|kglobalaccel*|kguiaddons*|kholidays*|ki18n*|kiconthemes*| \
    kidletime*|kimage*|kinfocenter*|kio*|kirigami*|kitem*|kjobwidgets*| \
    kjournald*|kmenuedit*|knewstuff*|knotifications*|knotify*|kolourpaint*| \
    kongress*|konsole*|kopeninghours*|kosmindoormap*|kpackage*|kparts*| \
    kpeople*|kpipewire*|kpmcore*|kpty*|kpublictransport*|kquick*|krdp*| \
    krunner*|ksane*|kscreen*|kservice*|ksshaskpass*|kstatus*|ksvg*| \
    ksystemstats*|kteatime*|ktext*|ktimer*|kunitconversion*|kuserfeedback*| \
    kwallet*|kwave*|kwayland*|kwidgetsaddons*|kwin*|kwindowsystem*|kwrited*| \
    kxmlgui*|kalk*|kate*|kbackup*|kddock*|knighttime*| \
    ark|attica|aurorae|baloo*|bluedevil|discover|dolphin*|drkonqi|filelight| \
    frameworkintegration|gwenview|milou|okular|partitionmanager|polkit-kde*| \
    powerdevil|print-manager|spectacle|systemsettings|sweeper|layer-shell-qt| \
    libplasma|plasma5support|qqc2-*|solid|sonnet|syndication|syntax-highlighting| \
    threadweaver|prison|purpose|kactivitymanagerd)
      assign "$pkg" "kde" ;;

    # --- Qt / GTK / GLib ---
    qt6-*)
      assign "$pkg" "qt6" ;;
    qt5-*)
      assign "$pkg" "qt5" ;;
    gtk*)
      assign "$pkg" "gtk" ;;
    glib2*|glib-*|glibmm*)
      assign "$pkg" "glib" ;;
    harfbuzz*)
      assign "$pkg" "harfbuzz" ;;

    # --- 32-bit libs ---
    lib32-*)
      assign "$pkg" "lib32" ;;

    # --- Language ecosystems ---
    python-*)
      assign "$pkg" "python" ;;
    perl-*)
      assign "$pkg" "perl" ;;
    haskell-*)
      assign "$pkg" "haskell" ;;

    # --- Virtualization / Wine ---
    qemu-*)
      assign "$pkg" "qemu" ;;
    wine*)
      assign "$pkg" "wine" ;;

    # --- Graphics / display ---
    mesa*)
      assign "$pkg" "mesa" ;;
    vulkan-*)
      assign "$pkg" "vulkan" ;;
    xf86-*)
      assign "$pkg" "xf86" ;;
    xorg-*)
      assign "$pkg" "xorg" ;;
    wayland*)
      assign "$pkg" "wayland" ;;
    xcb-*)
      assign "$pkg" "xcb" ;;
    xdg-*)
      assign "$pkg" "xdg" ;;

    # --- Multimedia ---
    vlc-plugin-*)
      assign "$pkg" "vlc-plugin" ;;
    vlc-*)
      assign "$pkg" "vlc" ;;
    ffmpeg*)
      assign "$pkg" "ffmpeg" ;;
    gst-plugin*|gst-plugins-*)
      assign "$pkg" "gstreamer-plugins" ;;
    gstreamer|gst-libav)
      assign "$pkg" "gstreamer" ;;
    sdl*)
      assign "$pkg" "sdl" ;;
    phonon-*)
      assign "$pkg" "phonon" ;;

    # --- Office / imaging ---
    libreoffice*)
      assign "$pkg" "libreoffice" ;;
    gimp*)
      assign "$pkg" "gimp" ;;
    ghostty*)
      assign "$pkg" "ghostty" ;;
    poppler*)
      assign "$pkg" "poppler" ;;

    # --- TeX / printing drivers ---
    texlive-*)
      assign "$pkg" "texlive" ;;
    foomatic-db*)
      assign "$pkg" "foomatic-db" ;;

    # --- Fonts ---
    ttf-*)
      assign "$pkg" "ttf-fonts" ;;
    noto-fonts*)
      assign "$pkg" "noto-fonts" ;;
    adobe-source-*)
      assign "$pkg" "adobe-source-fonts" ;;

    # --- Distro / system tools ---
    eos-*|endeavouros-*)
      assign "$pkg" "endeavouros" ;;
    cockpit*)
      assign "$pkg" "cockpit" ;;
    signon-*)
      assign "$pkg" "signon" ;;
    google-*)
      assign "$pkg" "google" ;;
    coin-or-*)
      assign "$pkg" "coin-or" ;;
    ca-certificates*)
      assign "$pkg" "ca-certificates" ;;
    zsh-*)
      assign "$pkg" "zsh" ;;
    wireshark*)
      assign "$pkg" "wireshark" ;;
    libblockdev*)
      assign "$pkg" "libblockdev" ;;

    # --- Remaining libraries ---
    lib*)
      assign "$pkg" "lib" ;;

    # --- Fallback ---
    *)
      assign "$pkg" "other" ;;
  esac
done < "$INPUT"

if (( PACKAGE_COUNT == 0 )); then
  echo "Error: no packages found after reading and de-duplicating '$INPUT'." >&2
  exit 2
fi

# -----------------------------------------------------------------------------
# Preferred output order
# -----------------------------------------------------------------------------
ORDER=(
  linux-kernel linux-firmware systemd dbus
  alsa pipewire bluez networkmanager cups
  kde plasma breeze oxygen adwaita sddm
  qt6 qt5 gtk glib harfbuzz
  lib32 lib libblockdev
  python perl haskell
  qemu wine mesa vulkan xf86 xorg wayland xcb xdg
  vlc vlc-plugin ffmpeg gstreamer gstreamer-plugins sdl phonon
  libreoffice gimp ghostty poppler
  texlive foomatic-db
  ttf-fonts noto-fonts adobe-source-fonts
  endeavouros cockpit signon google coin-or ca-certificates
  zsh wireshark
  other
)

# -----------------------------------------------------------------------------
# Write Markdown (or dry-run)
# -----------------------------------------------------------------------------
if [[ $DRY_RUN -eq 1 ]]; then
  echo "[dry-run] Would write grouped report to: $OUTPUT"
  echo "[dry-run] Unique packages processed: $PACKAGE_COUNT"
  echo
else
  {
    echo "# Installed Packages – Grouped (2 levels)"
    echo
    echo "Generated by: $SCRIPT_NAME v$SCRIPT_VERSION"
    echo "Date        : $(date '+%Y-%m-%d %H:%M %Z')"
    echo "Source      : $INPUT"
    echo
    echo "Total unique packages: $PACKAGE_COUNT"
    echo

    for group in "${ORDER[@]}"; do
      file="$TMPDIR/$group"
      [[ -f "$file" ]] || continue

      echo "## $group"
      echo
      sort -u "$file" | while read -r p; do
        echo "- $p"
      done
      echo
    done

    # leftover groups not listed in ORDER
    for file in "$TMPDIR"/*; do
      [[ -f "$file" ]] || continue
      group=$(basename "$file")
      if [[ ! " ${ORDER[*]} " =~ " $group " ]]; then
        echo "## $group"
        echo
        sort -u "$file" | while read -r p; do
          echo "- $p"
        done
        echo
      fi
    done
  } > "$OUTPUT"

  echo "Grouped list written to: $OUTPUT"
  echo "Unique packages processed: $PACKAGE_COUNT"
fi

# -----------------------------------------------------------------------------
# Summary (unless --quiet)
# -----------------------------------------------------------------------------
if [[ $QUIET -eq 0 ]]; then
  echo
  echo "Groups created:"
  for g in $(ls "$TMPDIR" | sort); do
    count=$(wc -l < "$TMPDIR/$g" | tr -d ' ')
    printf "  %-25s %4d\n" "$g" "$count"
  done | sort -k2 -nr
fi

To help with groups, you can also search for group members with pacman:

pacman -Qg | sort