Skip to content

Instantly share code, notes, and snippets.

@diegomarino
Created March 30, 2026 18:18
Show Gist options
  • Select an option

  • Save diegomarino/7606993d86f7498b07781ec6e2bb076c to your computer and use it in GitHub Desktop.

Select an option

Save diegomarino/7606993d86f7498b07781ec6e2bb076c to your computer and use it in GitHub Desktop.
macOS System Inventory Generator — YAML snapshots of installed packages (Homebrew, npm, bun, uv, mas, Claude Code plugins)
#!/usr/bin/env bash
# =============================================================================
# System Inventory Generator (macOS)
# =============================================================================
#
# Generates YAML snapshots of installed packages, organized by hostname.
# Files are saved to data/$(hostname)/ for version control.
#
# Requirements: macOS, bash 4+, Homebrew (optional tools: mas, jq, uv, bun)
#
# Usage:
# ./scripts/inventory.sh # Full inventory
# ./scripts/inventory.sh --brew # Only Homebrew
# ./scripts/inventory.sh --help # Show help
#
# =============================================================================
set -euo pipefail
# -----------------------------------------------------------------------------
# Configuration
# -----------------------------------------------------------------------------
# Use chezmoi source-path if available, otherwise fallback to script location
if command -v chezmoi >/dev/null 2>&1; then
BASE_DIR="$(chezmoi source-path)"
else
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BASE_DIR="$(dirname "$SCRIPT_DIR")"
fi
HOSTNAME="$(scutil --get LocalHostName 2>/dev/null || hostname -s)"
DATA_DIR="$BASE_DIR/data/$HOSTNAME"
# TIMESTAMP removed to avoid unnecessary git changes
# Colors
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m'
# -----------------------------------------------------------------------------
# Functions
# -----------------------------------------------------------------------------
info() { printf '%b[info]%b %s\n' "$BLUE" "$NC" "$1"; }
success() { printf '%b[ok]%b %s\n' "$GREEN" "$NC" "$1"; }
warn() { printf '%b[warn]%b %s\n' "$YELLOW" "$NC" "$1"; }
show_help() {
cat <<EOF
Usage: $(basename "$0") [OPTIONS]
Generate system inventory snapshots in YAML format.
Options:
--brew Homebrew packages only (includes taps)
--npm npm global packages only
--mas Mac App Store apps only
--apps /Applications listing only
--python Python packages only
--claude Claude Code plugins only
--bun bun global packages only
--uv uv global tools only
--all All inventories (default)
--restore Show restore commands for a hostname
--markdown Generate a Markdown catalog with descriptions
--help Show this help
Output:
Files saved to: data/$HOSTNAME/
Examples:
$(basename "$0") # Full inventory
$(basename "$0") --brew --npm # Specific inventories
$(basename "$0") --markdown # Generate Markdown catalog
$(basename "$0") --restore macbook # Show restore commands
EOF
}
# -----------------------------------------------------------------------------
# YAML Helper
# -----------------------------------------------------------------------------
yaml_header() {
local name="$1"
echo "# $name inventory for $HOSTNAME"
echo ""
}
yaml_list() {
local indent="${1:-2}"
local prefix=""
for ((i = 0; i < indent; i++)); do prefix+=" "; done
while IFS= read -r line; do
[[ -n "$line" ]] && echo "${prefix}- \"$line\""
done
}
# -----------------------------------------------------------------------------
# Inventory Functions
# -----------------------------------------------------------------------------
inventory_brew() {
if ! command -v brew >/dev/null 2>&1; then
warn "Homebrew not installed"
return 1
fi
# Brewfile (canonical format for full restore)
brew bundle dump --file="$DATA_DIR/Brewfile" --force 2>/dev/null
# Cache cask list (called once instead of 4+ times)
local all_casks
all_casks=$(brew list --cask 2>/dev/null)
# Taps
{
yaml_header "Homebrew taps"
echo "brew:"
echo " taps:"
brew tap | sort | yaml_list 4
} >"$DATA_DIR/brew-taps.yaml"
# Leaves (explicitly installed formulae, no dependencies)
{
yaml_header "Homebrew leaves"
echo "brew:"
echo " leaves:"
brew leaves | sort | yaml_list 4
} >"$DATA_DIR/brew-leaves.yaml"
# Casks (apps, excluding fonts)
{
yaml_header "Homebrew casks"
echo "brew:"
echo " casks:"
echo "$all_casks" | grep -v "^font-" | sort | yaml_list 4
} >"$DATA_DIR/brew-casks.yaml"
# Fonts
{
yaml_header "Homebrew fonts"
echo "brew:"
echo " fonts:"
echo "$all_casks" | grep "^font-" | sort | yaml_list 4
} >"$DATA_DIR/brew-fonts.yaml"
local taps leaves casks fonts
taps=$(brew tap | wc -l | xargs)
leaves=$(brew leaves | wc -l | xargs)
casks=$(echo "$all_casks" | grep -cv "^font-" || true)
fonts=$(echo "$all_casks" | grep -c "^font-" || true)
success "Homebrew: $taps taps, $leaves leaves, $casks casks, $fonts fonts"
}
inventory_npm() {
if ! command -v npm >/dev/null 2>&1; then
warn "npm not installed"
return 1
fi
{
yaml_header "npm global packages"
echo "npm:"
echo " global:"
npm ls -g --depth=0 --json 2>/dev/null |
jq -r '.dependencies | keys[]' 2>/dev/null |
sort | yaml_list 4
} >"$DATA_DIR/npm.yaml"
local count
count=$(npm ls -g --depth=0 --json 2>/dev/null | jq '.dependencies | length' 2>/dev/null || echo 0)
success "npm: $count global packages"
}
inventory_mas() {
if ! command -v mas >/dev/null 2>&1; then
warn "mas not installed (brew install mas)"
return 1
fi
{
yaml_header "Mac App Store"
echo "mas:"
echo " apps:"
mas list | sort -t' ' -k2 | while IFS= read -r line; do
local id name version
id=$(echo "$line" | awk '{print $1}')
name=$(echo "$line" | sed -E 's/^[0-9]+ (.*) \([^)]+\)$/\1/')
version=$(echo "$line" | grep -oE '\([^)]+\)$' | tr -d '()')
echo " - id: $id"
echo " name: \"$name\""
echo " version: \"$version\""
done
} >"$DATA_DIR/mas.yaml"
local count
count=$(mas list | wc -l | xargs)
success "Mac App Store: $count apps"
}
inventory_apps() {
if [[ ! -d "/Applications" ]]; then
warn "/Applications not found"
return 1
fi
{
yaml_header "Applications"
echo "applications:"
find /Applications -maxdepth 1 -name "*.app" -type d -print0 2>/dev/null |
xargs -0 -I {} basename {} .app |
sort | yaml_list 2
} >"$DATA_DIR/applications.yaml"
local count
count=$(find /Applications -maxdepth 1 -name "*.app" -type d 2>/dev/null | wc -l | xargs)
success "Applications: $count apps"
}
inventory_python() {
if ! command -v pip3 >/dev/null 2>&1; then
warn "pip3 not installed"
return 1
fi
{
yaml_header "Python packages"
echo "python:"
echo " version: \"$(python3 --version 2>&1 | awk '{print $2}')\""
echo " packages:"
pip3 list --format=freeze 2>/dev/null | sort | yaml_list 4
} >"$DATA_DIR/python.yaml"
local count
count=$(pip3 list --format=freeze 2>/dev/null | wc -l | xargs)
success "Python: $count packages"
}
inventory_uv() {
if ! command -v uv >/dev/null 2>&1; then
warn "uv not installed"
return 1
fi
{
yaml_header "uv tools"
echo "uv:"
echo " version: \"$(uv --version 2>/dev/null | awk '{print $2}')\""
echo " tools:"
uv tool list 2>/dev/null | grep -v '^-' | awk '{print $1}' | sort | yaml_list 4
} >"$DATA_DIR/uv.yaml"
local count
count=$(uv tool list 2>/dev/null | grep -cv '^-' || true)
success "uv: $count tools"
}
inventory_claude() {
local plugins_file="$HOME/.claude/plugins/installed_plugins.json"
local marketplaces_file="$HOME/.claude/plugins/known_marketplaces.json"
if [[ ! -f "$plugins_file" ]]; then
warn "Claude Code plugins not found"
return 1
fi
{
yaml_header "Claude Code"
echo "claude:"
# Marketplaces (excluding official)
echo " marketplaces:"
if [[ -f "$marketplaces_file" ]]; then
jq -r 'to_entries[] | select(.key != "claude-plugins-official") | " - name: \"\(.key)\"\n repo: \"\(.value.source.repo)\""' "$marketplaces_file" 2>/dev/null
fi
# Plugins
echo " plugins:"
jq -r '.plugins | to_entries[] | .key as $id | .value[0] | " - id: \"\($id)\"\n version: \"\(.version)\""' "$plugins_file" 2>/dev/null
} >"$DATA_DIR/claude.yaml"
local count
count=$(jq '.plugins | length' "$plugins_file" 2>/dev/null || echo 0)
success "Claude Code: $count plugins"
}
inventory_bun() {
if ! command -v bun >/dev/null 2>&1; then
warn "bun not installed"
return 1
fi
local bun_global="$HOME/.bun/install/global/node_modules"
{
yaml_header "bun global packages"
echo "bun:"
echo " version: \"$(bun --version 2>/dev/null)\""
echo " global:"
if [[ -d "$bun_global" ]]; then
for pkg_dir in "$bun_global"/*/; do
[[ -d "$pkg_dir" ]] || continue
local pkg ver
pkg=$(basename "$pkg_dir")
[[ "$pkg" == .* ]] && continue
ver=$(jq -r '.version // "unknown"' "$pkg_dir/package.json" 2>/dev/null)
echo " - \"${pkg}@${ver}\""
done
fi
} >"$DATA_DIR/bun.yaml"
local count=0
if [[ -d "$bun_global" ]]; then
for pkg_dir in "$bun_global"/*/; do
[[ -d "$pkg_dir" ]] || continue
local pkg
pkg=$(basename "$pkg_dir")
[[ "$pkg" == .* ]] && continue
((count++))
done
fi
success "bun: $count global packages"
}
# -----------------------------------------------------------------------------
# Markdown Catalog
# -----------------------------------------------------------------------------
generate_markdown() {
local outfile="$DATA_DIR/catalog.md"
info "Generating Markdown catalog..."
{
echo "# Software Catalog — $HOSTNAME"
echo ""
# --- Homebrew Formulae (leaves only) ---
if command -v brew >/dev/null 2>&1; then
local -a leaves
mapfile -t leaves < <(brew leaves | sort)
if [[ ${#leaves[@]} -gt 0 ]]; then
echo "## Homebrew Formulae"
echo ""
echo "| Package | Description |"
echo "|---------|-------------|"
brew desc --eval-all "${leaves[@]}" 2>/dev/null | sort | while IFS=: read -r name desc; do
desc="${desc# }"
echo "| \`$name\` | $desc |"
done
echo ""
fi
# --- Homebrew Casks (excluding fonts) ---
local -a casks
mapfile -t casks < <(brew list --cask | grep -v "^font-" | sort)
if [[ ${#casks[@]} -gt 0 ]]; then
echo "## Homebrew Casks"
echo ""
echo "| App | Description |"
echo "|-----|-------------|"
brew desc --eval-all "${casks[@]}" 2>/dev/null | sort | while IFS=: read -r name desc; do
desc="${desc# }"
# Strip the "(Display Name) " prefix that casks include
desc=$(echo "$desc" | sed -E 's/^\([^)]+\) //')
echo "| \`$name\` | $desc |"
done
echo ""
fi
# --- Homebrew Fonts ---
local -a fonts
mapfile -t fonts < <(brew list --cask | grep "^font-" | sort)
if [[ ${#fonts[@]} -gt 0 ]]; then
echo "## Homebrew Fonts"
echo ""
echo "| Font | Description |"
echo "|------|-------------|"
brew desc --eval-all "${fonts[@]}" 2>/dev/null | sort | while IFS=: read -r name desc; do
desc="${desc# }"
# Strip cask display name prefix: "(Name (Details)) desc" or "(Name) desc"
desc=$(echo "$desc" | sed -E 's/^\(.*\) *//')
[[ "$desc" == "[no description]" || -z "$desc" ]] && desc=""
echo "| \`$name\` | $desc |"
done
echo ""
fi
fi
# --- npm global ---
if command -v npm >/dev/null 2>&1; then
local -a npm_pkgs
mapfile -t npm_pkgs < <(npm ls -g --depth=0 --json 2>/dev/null | jq -r '.dependencies | keys[]' 2>/dev/null | sort)
if [[ ${#npm_pkgs[@]} -gt 0 ]]; then
echo "## npm Global Packages"
echo ""
echo "| Package | Description |"
echo "|---------|-------------|"
local npm_root
npm_root=$(npm root -g 2>/dev/null)
for pkg in "${npm_pkgs[@]}"; do
local desc
desc=$(jq -r '.description // "—"' "$npm_root/$pkg/package.json" 2>/dev/null)
echo "| \`$pkg\` | $desc |"
done
echo ""
fi
fi
# --- bun global ---
if command -v bun >/dev/null 2>&1; then
local bun_global="$HOME/.bun/install/global/node_modules"
if [[ -d "$bun_global" ]]; then
local -a bun_pkgs=()
for pkg_dir in "$bun_global"/*/; do
[[ -d "$pkg_dir" ]] || continue
local pkg
pkg=$(basename "$pkg_dir")
[[ "$pkg" == .* ]] && continue
bun_pkgs+=("$pkg")
done
if [[ ${#bun_pkgs[@]} -gt 0 ]]; then
echo "## bun Global Packages"
echo ""
echo "| Package | Description |"
echo "|---------|-------------|"
for pkg in "${bun_pkgs[@]}"; do
local desc
desc=$(jq -r '.description // "—"' "$bun_global/$pkg/package.json" 2>/dev/null)
echo "| \`$pkg\` | $desc |"
done
echo ""
fi
fi
fi
# --- Mac App Store ---
if command -v mas >/dev/null 2>&1; then
local mas_apps
mas_apps=$(mas list 2>/dev/null | sort -t' ' -k2)
if [[ -n "$mas_apps" ]]; then
echo "## Mac App Store"
echo ""
echo "| App | Version |"
echo "|-----|---------|"
while IFS= read -r line; do
local name version
# Format: " 497799835 Xcode (26.4)"
name=$(echo "$line" | sed -E 's/^[[:space:]]*[0-9]+[[:space:]]+(.*)[[:space:]]+\([^)]+\)$/\1/' | sed 's/[[:space:]]*$//')
version=$(echo "$line" | grep -oE '\([^)]+\)$' | tr -d '()')
echo "| \`$name\` | $version |"
done <<< "$mas_apps"
echo ""
fi
fi
# --- uv tools ---
if command -v uv >/dev/null 2>&1; then
local -a uv_tools
# uv tool list outputs "name vX.Y.Z" lines and "- command" lines; keep only tool names
mapfile -t uv_tools < <(uv tool list 2>/dev/null | grep -v '^-' | awk '{print $1}' | sort)
if [[ ${#uv_tools[@]} -gt 0 ]]; then
echo "## uv Tools"
echo ""
echo "| Tool |"
echo "|------|"
for tool in "${uv_tools[@]}"; do
echo "| \`$tool\` |"
done
echo ""
fi
fi
# --- Applications ---
if [[ -d "/Applications" ]]; then
echo "## Applications"
echo ""
echo "| App |"
echo "|-----|"
find /Applications -maxdepth 1 -name "*.app" -type d -print0 2>/dev/null |
xargs -0 -I {} basename {} .app |
sort | while IFS= read -r app; do
echo "| \`$app\` |"
done
echo ""
fi
} > "$outfile"
success "Markdown catalog: $outfile"
}
# -----------------------------------------------------------------------------
# Restore Commands
# -----------------------------------------------------------------------------
show_restore() {
local target_host="$1"
local target_dir="$BASE_DIR/data/$target_host"
if [[ ! -d "$target_dir" ]]; then
warn "No inventory found for: $target_host"
echo ""
echo "Available inventories:"
for dir in "$BASE_DIR/data"/*/; do
[[ -d "$dir" ]] && echo " - $(basename "$dir")"
done
exit 1
fi
echo ""
echo "Restore commands for: $target_host"
echo "=================================="
echo ""
# Homebrew (full)
if [[ -f "$target_dir/Brewfile" ]]; then
echo "# Homebrew (full restore - all packages):"
echo "brew bundle --file=$target_dir/Brewfile"
echo ""
fi
# Homebrew taps
if [[ -f "$target_dir/brew-taps.yaml" ]]; then
echo "# Homebrew taps:"
echo "yq '.brew.taps[]' $target_dir/brew-taps.yaml | xargs -I {} brew tap {}"
echo ""
fi
# Homebrew leaves only
if [[ -f "$target_dir/brew-leaves.yaml" ]]; then
echo "# Homebrew leaves (formulae only, no deps):"
echo "yq '.brew.leaves[]' $target_dir/brew-leaves.yaml | xargs brew install"
echo ""
fi
# Homebrew casks
if [[ -f "$target_dir/brew-casks.yaml" ]]; then
echo "# Homebrew casks (apps):"
echo "yq '.brew.casks[]' $target_dir/brew-casks.yaml | xargs brew install --cask"
echo ""
fi
# Homebrew fonts
if [[ -f "$target_dir/brew-fonts.yaml" ]]; then
echo "# Homebrew fonts:"
echo "yq '.brew.fonts[]' $target_dir/brew-fonts.yaml | xargs brew install --cask"
echo ""
fi
# npm
if [[ -f "$target_dir/npm.yaml" ]]; then
echo "# npm global packages:"
echo "yq '.npm.global[]' $target_dir/npm.yaml | xargs npm install -g"
echo ""
fi
# Mac App Store
if [[ -f "$target_dir/mas.yaml" ]]; then
echo "# Mac App Store apps:"
echo "yq '.mas.apps[].id' $target_dir/mas.yaml | xargs mas install"
echo ""
fi
# Python
if [[ -f "$target_dir/python.yaml" ]]; then
echo "# Python packages:"
echo "yq '.python.packages[]' $target_dir/python.yaml | xargs pip3 install"
echo ""
fi
# Claude Code
if [[ -f "$target_dir/claude.yaml" ]]; then
echo "# Claude Code marketplaces:"
echo "yq '.claude.marketplaces[].repo' $target_dir/claude.yaml | xargs -I {} claude /plugin marketplace add {}"
echo ""
echo "# Claude Code plugins:"
echo "yq '.claude.plugins[].id' $target_dir/claude.yaml | sed 's/@.*//' | xargs -I {} claude /plugin install {}"
echo ""
fi
# bun
if [[ -f "$target_dir/bun.yaml" ]]; then
echo "# bun global packages:"
echo "yq '.bun.global[]' $target_dir/bun.yaml | sed 's/@[^@]*$//' | xargs -I {} bun install -g {}"
echo ""
fi
# uv
if [[ -f "$target_dir/uv.yaml" ]]; then
echo "# uv global tools:"
echo "yq '.uv.tools[]' $target_dir/uv.yaml | xargs -I {} uv tool install {}"
echo ""
fi
}
# -----------------------------------------------------------------------------
# Main
# -----------------------------------------------------------------------------
main() {
local run_all=true
local run_brew=false
local run_npm=false
local run_mas=false
local run_apps=false
local run_python=false
local run_claude=false
local run_bun=false
local run_uv=false
local run_markdown=false
local restore_host=""
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--brew)
run_all=false
run_brew=true
;;
--npm)
run_all=false
run_npm=true
;;
--mas)
run_all=false
run_mas=true
;;
--apps)
run_all=false
run_apps=true
;;
--python)
run_all=false
run_python=true
;;
--claude)
run_all=false
run_claude=true
;;
--uv)
run_all=false
run_uv=true
;;
--bun)
run_all=false
run_bun=true
;;
--restore)
shift
restore_host="${1:-}"
if [[ -z "$restore_host" ]]; then
warn "--restore requires a hostname"
exit 1
fi
;;
--markdown)
run_markdown=true
;;
--all) run_all=true ;;
--help)
show_help
exit 0
;;
*)
warn "Unknown option: $1"
show_help
exit 1
;;
esac
shift
done
# Handle restore mode
if [[ -n "$restore_host" ]]; then
if ! $run_all; then
warn "Inventory flags ignored when using --restore"
fi
show_restore "$restore_host"
exit 0
fi
# Handle markdown mode
if $run_markdown; then
if ! $run_all; then
warn "Inventory flags ignored when using --markdown"
fi
mkdir -p "$DATA_DIR"
generate_markdown
exit 0
fi
echo ""
echo "System Inventory: $HOSTNAME"
echo "========================="
echo ""
# Create data directory
mkdir -p "$DATA_DIR"
# Run inventories
if $run_all || $run_brew; then inventory_brew || true; fi
if $run_all || $run_npm; then inventory_npm || true; fi
if $run_all || $run_mas; then inventory_mas || true; fi
if $run_all || $run_apps; then inventory_apps || true; fi
if $run_all || $run_python; then inventory_python || true; fi
if $run_all || $run_claude; then inventory_claude || true; fi
if $run_all || $run_bun; then inventory_bun || true; fi
if $run_all || $run_uv; then inventory_uv || true; fi
echo ""
info "Saved to: $DATA_DIR"
echo ""
echo "Next: git add data/ && git commit -m 'Update $HOSTNAME inventory'"
echo ""
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment