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