From ea76ff8b97e2ce083d04d760b67931fb449dc0f5 Mon Sep 17 00:00:00 2001 From: Ulises Chavarria Date: Mon, 22 Jun 2026 17:52:49 -0700 Subject: [PATCH 1/7] refactor(packages): per-platform field forms, merge duplicates, add tags Let priority/optional/environment accept a per-platform object (like install_command already did), resolved via new prfor/optfor/envfor jq defs in lib/core.sh; envok gains a $plat arg. Proven backward-compatible: old vs new engine produce byte-identical dry-run output on all four platforms. Merge the 7 duplicate entries that only existed to vary these fields per platform (git, git-xet, hf, llama.cpp, discord, notion, octave) into single entries. Add a required 'tags' category array (18-tag controlled vocabulary, metadata only) to all 97 entries. Add scripts/validate-packages.sh (jq) enforcing the platform vocabulary, the tag set, environment shape, and the no-silent-drop rule (every targeted platform must resolve a valid priority tier + boolean optional). Wired into pre-commit and CI (lint.yml). Also migrate poetry -> uv (uv bumped to high priority; poetry removed) across packages.json, README.md, and the macOS docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/lint.yml | 8 ++ .pre-commit-config.yaml | 7 ++ CLAUDE.md | 20 +++- README.md | 4 +- lib/core.sh | 30 +++--- lib/verify.sh | 4 +- macOS/README.md | 4 +- macOS/pipx_packages.md | 6 +- packages.json | 194 +++++++++++++++++++++-------------- platforms/macos.sh | 6 +- scripts/validate-packages.sh | 104 +++++++++++++++++++ 11 files changed, 282 insertions(+), 105 deletions(-) create mode 100755 scripts/validate-packages.sh diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 10509c4..12b28cf 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -35,6 +35,14 @@ jobs: done exit $failed + validate-packages: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Validate packages.json schema + run: bash scripts/validate-packages.sh + dryrun-smoke: runs-on: ubuntu-latest steps: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e869012..f4dd17a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,6 +14,13 @@ repos: types: [file] files: (\.zsh$|zshrc\.example$) + - id: validate-packages + name: validate packages.json schema + language: system + entry: bash scripts/validate-packages.sh + files: ^packages\.json$ + pass_filenames: false + - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: diff --git a/CLAUDE.md b/CLAUDE.md index 5eca18e..a94a147 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,10 +19,23 @@ tracked in TODO.md.) - `packages.json` — single source of truth for all package data. Managers are keyed by platform (`{macos, ubuntu, arch, server}`); `_name` - overrides the install token; `environment` tags gate on `--work`/`--personal`; + overrides the install token; `environment` gates on `--work`/`--personal`; `custom` managers carry `install_command` (string or per-platform object) — run by the engine when `handled_by_setup` is true, otherwise printed as a manual-install reminder. + - `priority`, `optional`, `environment`, and `install_command` each accept a + **scalar** (applies to every platform) **or a per-platform object** keyed by + platform (e.g. `"priority": { "macos": "medium", "ubuntu": "none" }`). The + engine resolves them via the `prfor`/`optfor`/`envfor`/`icfor` jq defs in + `lib/core.sh`. This is what lets one entry serve platforms that differ in + tier/optionality/gating, instead of splitting into duplicate entries. + - **`environment` caveat:** its scalar form is itself an *array* (`["work"]`), + so the per-platform form is detected as an *object* (`{ "ubuntu": ["work"] }`) + — array means legacy/all-platforms, object means per-platform. Keep the + per-platform value an object-of-arrays. + - `tags` — required non-empty array of descriptive categories from the + controlled vocabulary in `scripts/validate-packages.sh`. Metadata only + (grouping/docs); the install engine ignores them. - `lib/core.sh` — shared engine: arg parsing, platform detection, env filter, jq selection, install loops, config deploys. `lib/verify.sh` — check engine. - `platforms/.sh` — per-platform quirks only (bootstrap, manager @@ -47,7 +60,10 @@ phases. ## Conventions - Pre-commit runs `shellcheck --severity=warning` on all shell scripts; - `zsh -n` checks `.zsh` files and `zshrc.example`. + `zsh -n` checks `.zsh` files and `zshrc.example`; `scripts/validate-packages.sh` + enforces the `packages.json` schema (platform vocabulary, controlled tag set, + and the "no silent drop" rule — every platform a package targets must resolve a + valid priority tier and a boolean optional). All three also run in CI. - Probe semantics in `lib/verify.sh` are platform-faithful ports — macOS has no `command -v` fallback for casks/pipx/app-store, Linux falls back everywhere. Don't "fix" the asymmetry without checking `UNIFICATION.md` history. diff --git a/README.md b/README.md index 6c77b53..dc2d566 100644 --- a/README.md +++ b/README.md @@ -75,8 +75,8 @@ For OS-specific software and install instructions, see: 1. Official GitHub CLI — create PRs, manage issues, clone repos, and run Actions from the terminal 3. pyenv | macOS · Linux | [GitHub](https://github.com/pyenv/pyenv) | [brew](https://formulae.brew.sh/formula/pyenv#default) 1. Python version and virtual environment manager -4. poetry | macOS · Linux · Windows | [GitHub](https://github.com/python-poetry/poetry) - 1. Python project manager +4. uv | macOS · Linux · Windows | [Docs](https://docs.astral.sh/uv/) | [GitHub](https://github.com/astral-sh/uv) | [brew](https://formulae.brew.sh/formula/uv) + 1. Fast Python package and project manager — pip/venv/pyenv successor 5. pipx | macOS · Linux · Windows | [GitHub](https://github.com/pypa/pipx) | [brew](https://formulae.brew.sh/formula/pipx) 1. Install Python CLI tools in isolated virtual environments so they don't pollute the global Python install 6. git-lfs | macOS · Linux · Windows | [Download](https://git-lfs.com/) | [GitHub](https://github.com/git-lfs/git-lfs) | [brew](https://formulae.brew.sh/formula/git-lfs) diff --git a/lib/core.sh b/lib/core.sh index c89cb73..755c0cd 100755 --- a/lib/core.sh +++ b/lib/core.sh @@ -150,10 +150,14 @@ configure_pnpm() { # shellcheck disable=SC2016 # $vars below are jq variables, not shell expansions CORE_JQ_DEFS=' -def envok($w; $p): - (.environment == null) or - (($w == "true") and (.environment | index("work"))) or - (($p == "true") and (.environment | index("personal"))); +def prfor($plat): (.priority | if type == "object" then .[$plat] else . end); +def optfor($plat): (.optional | if type == "object" then .[$plat] else . end); +def envfor($plat): (.environment | if type == "object" then .[$plat] else . end); +def envok($plat; $w; $p): + (envfor($plat) as $e | + ($e == null) or + (($w == "true") and ($e | index("work"))) or + (($p == "true") and ($e | index("personal")))); def icfor($plat): (.install_command | if type == "object" then .[$plat] else . end); def pname($plat): (.[$plat + "_name"] // .name); @@ -166,7 +170,7 @@ pkg_names() { jq -r --arg plat "$PLATFORM" --arg m "$manager" --arg pr "$priority" \ --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" \ "$CORE_JQ_DEFS"'[.[] | select( - .package_manager[$plat] == $m and .priority == $pr and envok($w; $p) + .package_manager[$plat] == $m and prfor($plat) == $pr and envok($plat; $w; $p) ) | pname($plat)] | join(" ")' "$PACKAGES_JSON" } @@ -186,8 +190,8 @@ print_custom_reminders() { items=$(jq -r --arg plat "$PLATFORM" --arg pr "$priority" \ --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" \ "$CORE_JQ_DEFS"'.[] | select( - .package_manager[$plat] == "custom" and .priority == $pr and - (.handled_by_setup != true) and envok($w; $p) + .package_manager[$plat] == "custom" and prfor($plat) == $pr and + (.handled_by_setup != true) and envok($plat; $w; $p) ) | " - \(.name)\n \(.description)\n Install: \(icfor($plat))"' \ "$PACKAGES_JSON") [[ -n "$items" ]] && printf '%s\n' "$items" @@ -224,7 +228,7 @@ pipx_install_tier() { jq -r --arg plat "$PLATFORM" --arg pr "$priority" \ --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" \ "$CORE_JQ_DEFS"'.[] | select( - .package_manager[$plat] == "pipx" and .priority == $pr and envok($w; $p) + .package_manager[$plat] == "pipx" and prfor($plat) == $pr and envok($plat; $w; $p) ) | (icfor($plat) // ("pipx install " + .name))' "$PACKAGES_JSON" | while read -r cmd; do run_eval "$cmd" @@ -237,7 +241,7 @@ pnpm_install_tier() { names=$(jq -r --arg plat "$PLATFORM" --arg pr "$priority" \ --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" \ "$CORE_JQ_DEFS"'[.[] | select( - .package_manager[$plat] == "pnpm" and .priority == $pr and envok($w; $p) + .package_manager[$plat] == "pnpm" and prfor($plat) == $pr and envok($plat; $w; $p) ) | .name] | join(" ")' "$PACKAGES_JSON") [[ -z "$names" ]] && return 0 # shellcheck disable=SC2086 @@ -381,16 +385,16 @@ snap_install_tier() { regular_snaps=$(jq -r --arg plat "$PLATFORM" --arg pr "$priority" \ --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" \ "$CORE_JQ_DEFS"'[.[] | select( - .package_manager[$plat] == "snap" and .priority == $pr and - envok($w; $p) and (icfor($plat) == null) + .package_manager[$plat] == "snap" and prfor($plat) == $pr and + envok($plat; $w; $p) and (icfor($plat) == null) ) | pname($plat)] | join(" ")' "$PACKAGES_JSON") # Snaps needing flags (e.g. --classic) carry their full install_command. # shellcheck disable=SC2016 custom_snaps=$(jq -r --arg plat "$PLATFORM" --arg pr "$priority" \ --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" \ "$CORE_JQ_DEFS"'.[] | select( - .package_manager[$plat] == "snap" and .priority == $pr and - envok($w; $p) and (icfor($plat) != null) + .package_manager[$plat] == "snap" and prfor($plat) == $pr and + envok($plat; $w; $p) and (icfor($plat) != null) ) | "\(.name)|\(icfor($plat))"' "$PACKAGES_JSON") if [[ -n "$regular_snaps" ]]; then diff --git a/lib/verify.sh b/lib/verify.sh index 35103bd..74b0db2 100755 --- a/lib/verify.sh +++ b/lib/verify.sh @@ -106,8 +106,8 @@ verify_section() { rows=$(jq -r --arg plat "$PLATFORM" --arg pr "$priority" \ --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" \ "$CORE_JQ_DEFS"'.[] | select( - .package_manager[$plat] != null and .priority == $pr and envok($w; $p) - ) | [.name, .package_manager[$plat], pname($plat), (.optional | tostring)] | @tsv' \ + .package_manager[$plat] != null and prfor($plat) == $pr and envok($plat; $w; $p) + ) | [.name, .package_manager[$plat], pname($plat), (optfor($plat) | tostring)] | @tsv' \ "$PACKAGES_JSON") [[ -z "$rows" ]] && return 0 diff --git a/macOS/README.md b/macOS/README.md index 5d384c7..5e6342a 100644 --- a/macOS/README.md +++ b/macOS/README.md @@ -4,10 +4,10 @@ 1. Homebrew | [Download](https://brew.sh/) 1. The missing package manager for macOS — see [brew_packages.md](brew_packages.md) for the full list of useful formulae - 2. Packages: `gh`, `hf`, `git-lfs`, `git-xet`, `fastfetch`, `pipx`, `pyenv`, `sshpass`, `gnupg`, `antidote`, `htop`, `nvtop`, `mactop`, `micro`, `tmux`, `llama.cpp`, and more (zsh plugins are managed by antidote — see [dotfiles/zsh_plugins.txt](../dotfiles/zsh_plugins.txt)) + 2. Packages: `gh`, `hf`, `git-lfs`, `git-xet`, `fastfetch`, `pipx`, `pyenv`, `uv`, `sshpass`, `gnupg`, `antidote`, `htop`, `nvtop`, `mactop`, `micro`, `tmux`, `llama.cpp`, and more (zsh plugins are managed by antidote — see [dotfiles/zsh_plugins.txt](../dotfiles/zsh_plugins.txt)) 2. pipx | [brew](https://formulae.brew.sh/formula/pipx) 1. Installs Python CLI tools in isolated virtual environments — see [pipx_packages.md](pipx_packages.md) for the full list - 2. Packages: `poetry`, and more + 2. Packages: `fluidtop`, `exifread`, and more ## Development diff --git a/macOS/pipx_packages.md b/macOS/pipx_packages.md index 6233ffd..d68030b 100644 --- a/macOS/pipx_packages.md +++ b/macOS/pipx_packages.md @@ -2,9 +2,7 @@ Install via `pipx install `. Requires [pipx](https://formulae.brew.sh/formula/pipx). -1. poetry | [PyPI](https://pypi.org/project/poetry/) | [GitHub](https://github.com/python-poetry/poetry) - 1. Python project and dependency manager — handles virtualenvs, packaging, and publishing -2. fluidtop | [PyPI](https://pypi.org/project/fluidtop/) | [GitHub](https://github.com/FluidInference/fluidtop) +1. fluidtop | [PyPI](https://pypi.org/project/fluidtop/) | [GitHub](https://github.com/FluidInference/fluidtop) 1. Real-time Apple Silicon performance monitor with AI workload focus — enhanced alternative to asitop with M1/M2/M3/M4 support -3. exifread | [PyPI](https://pypi.org/project/exifread/) | [GitHub](https://github.com/ianare/exif-py) +2. exifread | [PyPI](https://pypi.org/project/exifread/) | [GitHub](https://github.com/ianare/exif-py) 1. Python library and CLI (`EXIF.py`) for reading EXIF metadata from JPEG and TIFF photos diff --git a/packages.json b/packages.json index 65d7cbb..0b89102 100644 --- a/packages.json +++ b/packages.json @@ -1,6 +1,7 @@ [ { "name": "zsh", + "tags": ["terminal"], "package_manager": { "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "high", "optional": false, @@ -8,6 +9,7 @@ }, { "name": "zsh-antidote", + "tags": ["terminal"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "macos_name": "antidote", "priority": "high", @@ -16,6 +18,7 @@ }, { "name": "zoxide", + "tags": ["terminal"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay" }, "priority": "high", "optional": false, @@ -23,6 +26,7 @@ }, { "name": "expat", + "tags": ["development"], "package_manager": { "macos": "brew" }, "priority": "high", "optional": false, @@ -30,6 +34,7 @@ }, { "name": "pyenv", + "tags": ["development"], "package_manager": { "macos": "brew", "ubuntu": "custom", "arch": "custom" }, "priority": "high", "optional": false, @@ -39,6 +44,7 @@ }, { "name": "nvm", + "tags": ["development"], "package_manager": { "macos": "custom", "ubuntu": "custom", "arch": "custom", "server": "custom" }, "priority": "high", "optional": false, @@ -48,6 +54,7 @@ }, { "name": "pipx", + "tags": ["development"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay" }, "arch_name": "python-pipx", "priority": "high", @@ -56,13 +63,15 @@ }, { "name": "uv", + "tags": ["development"], "package_manager": { "macos": "brew", "ubuntu": "pipx", "arch": "yay" }, - "priority": "medium", + "priority": "high", "optional": false, "description": "Fast Python package and project manager — pip/venv/pyenv successor" }, { "name": "build-essential", + "tags": ["development"], "package_manager": { "ubuntu": "apt", "arch": "yay", "server": "apt" }, "arch_name": "base-devel", "priority": "high", @@ -71,20 +80,15 @@ }, { "name": "git", - "package_manager": { "ubuntu": "apt", "arch": "yay" }, - "priority": "high", + "tags": ["development"], + "package_manager": { "ubuntu": "apt", "arch": "yay", "server": "apt" }, + "priority": { "ubuntu": "high", "arch": "high", "server": "medium" }, "optional": false, "description": "Version control — usually pre-installed but listed to ensure it's present" }, - { - "name": "git", - "package_manager": { "server": "apt" }, - "priority": "medium", - "optional": false, - "description": "Version control — usually pre-installed on Ubuntu Server" - }, { "name": "rust", + "tags": ["development"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "ubuntu_name": "rustup", "arch_name": "rustup", @@ -95,6 +99,7 @@ }, { "name": "go", + "tags": ["development"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay" }, "ubuntu_name": "golang-go", "priority": "medium", @@ -103,6 +108,7 @@ }, { "name": "ghostty", + "tags": ["terminal"], "package_manager": { "macos": "brew-cask", "ubuntu": "snap", "arch": "yay" }, "priority": "medium", "optional": false, @@ -111,6 +117,7 @@ }, { "name": "fastfetch", + "tags": ["terminal", "system-monitoring"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "medium", "optional": false, @@ -118,6 +125,7 @@ }, { "name": "gh", + "tags": ["development"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "arch_name": "github-cli", "priority": "medium", @@ -126,6 +134,7 @@ }, { "name": "forgejo-cli", + "tags": ["development"], "package_manager": { "macos": "brew", "ubuntu": "custom", "arch": "custom" }, "priority": "medium", "optional": false, @@ -134,6 +143,7 @@ }, { "name": "git-lfs", + "tags": ["development"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "medium", "optional": false, @@ -141,22 +151,17 @@ }, { "name": "git-xet", - "package_manager": { "macos": "brew" }, - "priority": "medium", - "optional": false, - "description": "Git LFS plugin using the Xet protocol for Hugging Face Hub" - }, - { - "name": "git-xet", - "package_manager": { "ubuntu": "custom", "arch": "custom" }, + "tags": ["development", "local-llm"], + "package_manager": { "macos": "brew", "ubuntu": "custom", "arch": "custom" }, "priority": "medium", "optional": false, - "environment": ["personal"], + "environment": { "ubuntu": ["personal"], "arch": ["personal"] }, "install_command": "curl --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/huggingface/xet-core/refs/heads/main/git_xet/install.sh | sh", "description": "Git LFS plugin using the Xet protocol for Hugging Face Hub" }, { "name": "pre-commit", + "tags": ["development"], "package_manager": { "ubuntu": "pipx", "arch": "yay" }, "priority": "medium", "optional": false, @@ -164,6 +169,7 @@ }, { "name": "shellcheck", + "tags": ["development"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "medium", "optional": false, @@ -171,6 +177,7 @@ }, { "name": "codespell", + "tags": ["development"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay" }, "priority": "medium", "optional": false, @@ -178,6 +185,7 @@ }, { "name": "pytest", + "tags": ["development"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "ubuntu_name": "python3-pytest", "arch_name": "python-pytest", @@ -188,6 +196,7 @@ }, { "name": "swiftlint", + "tags": ["development"], "package_manager": { "macos": "brew" }, "priority": "medium", "optional": false, @@ -195,6 +204,7 @@ }, { "name": "swiftformat", + "tags": ["development"], "package_manager": { "macos": "brew" }, "priority": "medium", "optional": false, @@ -202,6 +212,7 @@ }, { "name": "xcodegen", + "tags": ["development"], "package_manager": { "macos": "brew" }, "priority": "medium", "optional": false, @@ -209,6 +220,7 @@ }, { "name": "gnupg", + "tags": ["security"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "medium", "optional": false, @@ -216,22 +228,17 @@ }, { "name": "hf", - "package_manager": { "macos": "brew" }, - "priority": "medium", - "optional": false, - "description": "Hugging Face Hub CLI — download, upload, and manage ML models" - }, - { - "name": "hf", - "package_manager": { "ubuntu": "pipx", "arch": "pipx" }, + "tags": ["local-llm"], + "package_manager": { "macos": "brew", "ubuntu": "pipx", "arch": "pipx" }, "priority": "medium", "optional": false, - "environment": ["personal"], - "install_command": "pipx install huggingface-hub[cli]", + "environment": { "ubuntu": ["personal"], "arch": ["personal"] }, + "install_command": { "ubuntu": "pipx install huggingface-hub[cli]", "arch": "pipx install huggingface-hub[cli]" }, "description": "Hugging Face Hub CLI — download, upload, and manage ML models" }, { "name": "htop", + "tags": ["system-monitoring"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "medium", "optional": false, @@ -239,21 +246,16 @@ }, { "name": "llama.cpp", - "package_manager": { "macos": "brew" }, - "priority": "medium", - "optional": false, + "tags": ["local-llm"], + "package_manager": { "macos": "brew", "ubuntu": "custom", "arch": "yay" }, + "priority": { "macos": "medium", "ubuntu": "none", "arch": "none" }, + "optional": { "macos": false, "ubuntu": true, "arch": true }, + "install_command": { "ubuntu": "See https://github.com/ggml-org/llama.cpp#build" }, "description": "LLM inference engine — run local models on CPU and Apple Silicon GPU" }, - { - "name": "llama.cpp", - "package_manager": { "ubuntu": "custom", "arch": "yay" }, - "priority": "none", - "optional": true, - "install_command": "See https://github.com/ggml-org/llama.cpp#build", - "description": "LLM inference engine — run local models on CPU/GPU" - }, { "name": "mlx-lm", + "tags": ["local-llm"], "package_manager": { "macos": "brew" }, "priority": "medium", "optional": false, @@ -261,6 +263,7 @@ }, { "name": "mactop", + "tags": ["system-monitoring"], "package_manager": { "macos": "brew" }, "priority": "medium", "optional": false, @@ -268,6 +271,7 @@ }, { "name": "micro", + "tags": ["terminal"], "package_manager": { "macos": "brew", "ubuntu": "snap", "arch": "yay", "server": "snap" }, "priority": "medium", "optional": false, @@ -276,6 +280,7 @@ }, { "name": "nvtop", + "tags": ["system-monitoring"], "package_manager": { "macos": "brew", "ubuntu": "snap", "arch": "yay", "server": "snap" }, "priority": "medium", "optional": false, @@ -283,6 +288,7 @@ }, { "name": "lact", + "tags": ["system-monitoring"], "package_manager": { "arch": "yay" }, "priority": "medium", "optional": false, @@ -290,6 +296,7 @@ }, { "name": "sshpass", + "tags": ["networking"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "medium", "optional": false, @@ -297,6 +304,7 @@ }, { "name": "tmux", + "tags": ["terminal"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "medium", "optional": false, @@ -304,6 +312,7 @@ }, { "name": "eza", + "tags": ["terminal"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "medium", "optional": false, @@ -311,6 +320,7 @@ }, { "name": "terminal-notifier", + "tags": ["desktop-utility"], "package_manager": { "macos": "brew" }, "priority": "medium", "optional": false, @@ -318,6 +328,7 @@ }, { "name": "fzf", + "tags": ["terminal"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "medium", "optional": false, @@ -325,6 +336,7 @@ }, { "name": "ripgrep", + "tags": ["terminal"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "medium", "optional": false, @@ -332,6 +344,7 @@ }, { "name": "bat", + "tags": ["terminal"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "medium", "optional": false, @@ -339,6 +352,7 @@ }, { "name": "fd", + "tags": ["terminal"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "ubuntu_name": "fd-find", "server_name": "fd-find", @@ -348,6 +362,7 @@ }, { "name": "jq", + "tags": ["development"], "package_manager": { "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "medium", "optional": false, @@ -355,6 +370,7 @@ }, { "name": "jq", + "tags": ["development"], "package_manager": { "macos": "brew" }, "priority": "low", "optional": true, @@ -362,6 +378,7 @@ }, { "name": "curl", + "tags": ["networking"], "package_manager": { "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "medium", "optional": false, @@ -369,6 +386,7 @@ }, { "name": "wget", + "tags": ["networking"], "package_manager": { "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "medium", "optional": false, @@ -376,6 +394,7 @@ }, { "name": "libnotify-bin", + "tags": ["desktop-utility"], "package_manager": { "ubuntu": "apt", "arch": "yay" }, "arch_name": "libnotify", "priority": "medium", @@ -384,6 +403,7 @@ }, { "name": "ncdu", + "tags": ["system-monitoring"], "package_manager": { "server": "apt" }, "priority": "medium", "optional": false, @@ -391,6 +411,7 @@ }, { "name": "cockpit", + "tags": ["system-monitoring", "networking"], "package_manager": { "server": "apt" }, "priority": "medium", "optional": false, @@ -398,6 +419,7 @@ }, { "name": "tailscale", + "tags": ["networking"], "package_manager": { "macos": "app-store", "ubuntu": "custom", "arch": "yay", "server": "custom" }, "macos_name": "Tailscale", "priority": "medium", @@ -408,6 +430,7 @@ }, { "name": "docker", + "tags": ["development", "containers"], "package_manager": { "macos": "brew-cask", "ubuntu": "custom", "arch": "yay" }, "macos_name": "docker-desktop", "priority": "low", @@ -418,6 +441,7 @@ }, { "name": "claude-code", + "tags": ["ai-coding"], "package_manager": { "macos": "custom", "ubuntu": "custom", "arch": "custom", "server": "custom" }, "priority": "medium", "optional": false, @@ -427,6 +451,7 @@ }, { "name": "opencode", + "tags": ["ai-coding"], "package_manager": { "macos": "brew", "ubuntu": "custom", "arch": "yay" }, "macos_name": "anomalyco/tap/opencode", "arch_name": "opencode-bin", @@ -437,6 +462,7 @@ }, { "name": "claude-desktop", + "tags": ["ai-coding"], "package_manager": { "macos": "brew-cask", "ubuntu": "custom", "arch": "yay" }, "macos_name": "claude", "arch_name": "claude-desktop-bin", @@ -447,6 +473,7 @@ }, { "name": "visual-studio-code", + "tags": ["development"], "package_manager": { "macos": "brew-cask", "ubuntu": "snap", "arch": "yay" }, "ubuntu_name": "code", "arch_name": "visual-studio-code-bin", @@ -457,6 +484,7 @@ }, { "name": "firefox", + "tags": ["browser"], "package_manager": { "macos": "brew-cask", "ubuntu": "snap", "arch": "yay" }, "priority": "medium", "optional": false, @@ -464,6 +492,7 @@ }, { "name": "zen-browser", + "tags": ["browser"], "package_manager": { "macos": "brew-cask", "ubuntu": "custom", "arch": "yay" }, "macos_name": "zen", "arch_name": "zen-browser-bin", @@ -474,6 +503,7 @@ }, { "name": "chromium", + "tags": ["browser"], "package_manager": { "ubuntu": "snap", "arch": "yay" }, "priority": "medium", "optional": false, @@ -482,6 +512,7 @@ }, { "name": "obs-studio", + "tags": ["media"], "package_manager": { "macos": "brew-cask", "ubuntu": "apt", "arch": "yay" }, "macos_name": "obs", "priority": "medium", @@ -490,6 +521,7 @@ }, { "name": "vlc", + "tags": ["media"], "package_manager": { "macos": "brew-cask", "ubuntu": "apt", "arch": "yay" }, "priority": "medium", "optional": false, @@ -497,21 +529,16 @@ }, { "name": "discord", - "package_manager": { "macos": "brew-cask" }, - "priority": "medium", - "optional": false, - "description": "Voice, video, and text chat platform" - }, - { - "name": "discord", - "package_manager": { "ubuntu": "snap", "arch": "yay" }, + "tags": ["communication", "entertainment"], + "package_manager": { "macos": "brew-cask", "ubuntu": "snap", "arch": "yay" }, "priority": "medium", "optional": false, - "environment": ["personal"], + "environment": { "ubuntu": ["personal"], "arch": ["personal"] }, "description": "Voice, video, and text chat platform" }, { "name": "bitwarden", + "tags": ["security"], "package_manager": { "macos": "app-store", "ubuntu": "snap", "arch": "yay" }, "macos_name": "Bitwarden", "priority": "medium", @@ -520,6 +547,7 @@ }, { "name": "slack", + "tags": ["communication"], "package_manager": { "macos": "brew-cask", "ubuntu": "snap", "arch": "yay" }, "arch_name": "slack-desktop", "priority": "medium", @@ -529,6 +557,7 @@ }, { "name": "zoom", + "tags": ["communication"], "package_manager": { "macos": "brew-cask", "ubuntu": "snap", "arch": "yay" }, "ubuntu_name": "zoom-client", "priority": "medium", @@ -538,6 +567,7 @@ }, { "name": "microsoft-outlook", + "tags": ["communication"], "package_manager": { "macos": "brew-cask" }, "priority": "medium", "optional": false, @@ -546,22 +576,16 @@ }, { "name": "teams-for-linux", + "tags": ["communication"], "package_manager": { "ubuntu": "snap", "arch": "yay" }, "priority": "medium", "optional": false, "environment": ["work"], "description": "Unofficial Microsoft Teams client for Linux" }, - { - "name": "poetry", - "package_manager": { "macos": "pipx", "ubuntu": "pipx", "arch": "yay" }, - "arch_name": "python-poetry", - "priority": "medium", - "optional": false, - "description": "Python project and dependency manager" - }, { "name": "codeburn", + "tags": ["development", "ai-coding"], "package_manager": { "macos": "pnpm", "ubuntu": "pnpm", "arch": "pnpm" }, "priority": "medium", "optional": false, @@ -569,6 +593,7 @@ }, { "name": "mac-mouse-fix", + "tags": ["desktop-utility"], "package_manager": { "macos": "brew-cask" }, "priority": "medium", "optional": false, @@ -576,6 +601,7 @@ }, { "name": "rectangle", + "tags": ["desktop-utility"], "package_manager": { "macos": "brew-cask" }, "priority": "medium", "optional": false, @@ -583,6 +609,7 @@ }, { "name": "caffeine", + "tags": ["desktop-utility"], "package_manager": { "macos": "brew-cask" }, "priority": "medium", "optional": false, @@ -590,6 +617,7 @@ }, { "name": "google-drive", + "tags": ["cloud-storage"], "package_manager": { "macos": "brew-cask" }, "priority": "medium", "optional": false, @@ -597,6 +625,7 @@ }, { "name": "amazon-photos", + "tags": ["photos", "cloud-storage"], "package_manager": { "macos": "brew-cask" }, "priority": "medium", "optional": false, @@ -604,23 +633,18 @@ }, { "name": "notion", - "package_manager": { "macos": "brew-cask" }, - "priority": "medium", - "optional": false, - "description": "Notes and project management" - }, - { - "name": "notion", - "package_manager": { "ubuntu": "snap", "arch": "yay" }, + "tags": ["productivity"], + "package_manager": { "macos": "brew-cask", "ubuntu": "snap", "arch": "yay" }, "ubuntu_name": "notion-snap-reborn", "arch_name": "notion-app-electron", - "priority": "low", - "optional": true, - "environment": ["personal"], + "priority": { "macos": "medium", "ubuntu": "low", "arch": "low" }, + "optional": { "macos": false, "ubuntu": true, "arch": true }, + "environment": { "ubuntu": ["personal"], "arch": ["personal"] }, "description": "Notes and project management — unofficial snap/AUR packages on Linux" }, { "name": "obsidian", + "tags": ["productivity"], "package_manager": { "macos": "brew-cask", "ubuntu": "snap", "arch": "yay" }, "priority": "medium", "optional": false, @@ -629,6 +653,7 @@ }, { "name": "anki", + "tags": ["productivity"], "package_manager": { "macos": "brew-cask", "ubuntu": "custom", "arch": "yay" }, "priority": "medium", "optional": false, @@ -637,6 +662,7 @@ }, { "name": "Spark Mail – AI Email Inbox", + "tags": ["communication"], "macos_name": "Spark Desktop", "package_manager": { "macos": "app-store" }, "priority": "medium", @@ -645,6 +671,7 @@ }, { "name": "Photomator", + "tags": ["photos"], "package_manager": { "macos": "app-store" }, "priority": "medium", "optional": false, @@ -652,6 +679,7 @@ }, { "name": "net-tools", + "tags": ["networking"], "package_manager": { "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "low", "optional": true, @@ -659,6 +687,7 @@ }, { "name": "poppler", + "tags": ["media"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay" }, "ubuntu_name": "poppler-utils", "priority": "low", @@ -667,6 +696,7 @@ }, { "name": "ffmpeg", + "tags": ["media"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "low", "optional": true, @@ -674,6 +704,7 @@ }, { "name": "exiftool", + "tags": ["media", "photos"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "ubuntu_name": "libimage-exiftool-perl", "arch_name": "perl-image-exiftool", @@ -684,6 +715,7 @@ }, { "name": "tesseract", + "tags": ["media"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay" }, "ubuntu_name": "tesseract-ocr", "priority": "low", @@ -692,6 +724,7 @@ }, { "name": "smartmontools", + "tags": ["system-monitoring"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "low", "optional": true, @@ -699,6 +732,7 @@ }, { "name": "nmap", + "tags": ["networking"], "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, "priority": "low", "optional": true, @@ -706,6 +740,7 @@ }, { "name": "samba", + "tags": ["networking"], "package_manager": { "server": "apt" }, "priority": "low", "optional": true, @@ -713,6 +748,7 @@ }, { "name": "gogcli", + "tags": ["productivity"], "package_manager": { "macos": "brew" }, "priority": "low", "optional": true, @@ -720,6 +756,7 @@ }, { "name": "fluidtop", + "tags": ["system-monitoring"], "package_manager": { "macos": "pipx" }, "priority": "low", "optional": true, @@ -727,6 +764,7 @@ }, { "name": "exifread", + "tags": ["media", "photos"], "package_manager": { "macos": "pipx" }, "priority": "low", "optional": true, @@ -734,6 +772,7 @@ }, { "name": "Goodnotes", + "tags": ["productivity"], "package_manager": { "macos": "app-store" }, "priority": "low", "optional": true, @@ -741,6 +780,7 @@ }, { "name": "spotify", + "tags": ["entertainment"], "package_manager": { "ubuntu": "snap", "arch": "yay" }, "priority": "low", "optional": true, @@ -749,6 +789,7 @@ }, { "name": "steam", + "tags": ["entertainment"], "package_manager": { "ubuntu": "apt", "arch": "yay" }, "priority": "low", "optional": true, @@ -757,6 +798,7 @@ }, { "name": "bolt-launcher", + "tags": ["entertainment"], "package_manager": { "arch": "yay" }, "priority": "low", "optional": true, @@ -765,21 +807,16 @@ }, { "name": "octave", - "package_manager": { "macos": "brew" }, - "priority": "none", - "optional": true, - "description": "MATLAB-compatible numerical computing environment" - }, - { - "name": "octave", - "package_manager": { "ubuntu": "apt", "arch": "yay" }, + "tags": ["science"], + "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay" }, "priority": "none", "optional": true, - "environment": ["work"], + "environment": { "ubuntu": ["work"], "arch": ["work"] }, "description": "MATLAB-compatible numerical computing environment" }, { "name": "qemu", + "tags": ["containers"], "package_manager": { "macos": "brew" }, "priority": "none", "optional": true, @@ -787,6 +824,7 @@ }, { "name": "mongodb-community", + "tags": ["database"], "package_manager": { "macos": "custom" }, "priority": "none", "optional": true, @@ -795,6 +833,7 @@ }, { "name": "Pixelmator Pro", + "tags": ["photos"], "package_manager": { "macos": "app-store" }, "priority": "none", "optional": true, @@ -802,6 +841,7 @@ }, { "name": "Amphetamine", + "tags": ["desktop-utility"], "package_manager": { "macos": "app-store" }, "priority": "none", "optional": true, diff --git a/platforms/macos.sh b/platforms/macos.sh index 43be527..e8c11a6 100755 --- a/platforms/macos.sh +++ b/platforms/macos.sh @@ -52,8 +52,8 @@ mac_custom_install_tier() { jq -r --arg plat "$PLATFORM" --arg pr "$priority" \ --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" \ "$CORE_JQ_DEFS"'.[] | select( - .package_manager[$plat] == "custom" and .priority == $pr and - envok($w; $p) and (icfor($plat) != null) and .name != "nvm" + .package_manager[$plat] == "custom" and prfor($plat) == $pr and + envok($plat; $w; $p) and (icfor($plat) != null) and .name != "nvm" ) | icfor($plat)' "$PACKAGES_JSON" | while read -r cmd; do run_eval "$cmd" @@ -75,7 +75,7 @@ print_app_store_reminders() { apps=$(jq -r --arg plat "$PLATFORM" --arg pr "$priority" \ --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" \ "$CORE_JQ_DEFS"'.[] | select( - .package_manager[$plat] == "app-store" and .priority == $pr and envok($w; $p) + .package_manager[$plat] == "app-store" and prfor($plat) == $pr and envok($plat; $w; $p) ) | " - \(.name): \(.description)"' "$PACKAGES_JSON") [[ -n "$apps" ]] && printf '%s\n' "$apps" return 0 diff --git a/scripts/validate-packages.sh b/scripts/validate-packages.sh new file mode 100755 index 0000000..473dd3f --- /dev/null +++ b/scripts/validate-packages.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# Schema validator for packages.json. Enforces the invariants the install +# engine relies on but cannot check at runtime — most importantly the +# "no silent drop" rule: every platform listed in a package's package_manager +# must resolve to a valid priority tier and a boolean optional, so a typo'd or +# missing per-platform key fails here at commit time instead of silently +# dropping the package from every install. +# +# Validates: +# - package_manager is an object; its keys are known platforms +# - per-platform priority/optional/environment/install_command objects only +# key platforms that the package actually targets +# - every targeted platform resolves a valid priority (high/medium/low/none) +# and a boolean optional (scalar or per-platform object form both allowed) +# - environment is a tag array (legacy) or a per-platform object of tag arrays +# - tags is a non-empty array drawn from the controlled vocabulary +# +# Usage: bash scripts/validate-packages.sh + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PKG="${1:-$ROOT/packages.json}" + +command -v jq >/dev/null || { printf 'error: jq is required\n' >&2; exit 1; } + +if ! jq empty "$PKG" 2>/dev/null; then + printf 'error: %s is not valid JSON\n' "$PKG" >&2 + exit 1 +fi + +# shellcheck disable=SC2016 # $vars below are jq variables, not shell expansions +errors=$(jq -r ' + def PLATFORMS: ["macos","ubuntu","arch","server"]; + def TIERS: ["high","medium","low","none"]; + def ENVS: ["work","personal"]; + def TAGS: ["ai-coding","browser","cloud-storage","communication","containers", + "database","desktop-utility","development","entertainment","local-llm", + "media","networking","photos","productivity","science","security", + "system-monitoring","terminal"]; + def prfor($p): (.priority | if type == "object" then .[$p] else . end); + def optfor($p): (.optional | if type == "object" then .[$p] else . end); + + to_entries[] + | .key as $i | .value as $e + | ($e.name // "") as $nm + | $e + | [ + (if (.package_manager | type) != "object" + then "\($nm): package_manager must be an object" else empty end), + + ((.package_manager | keys[]) as $k + | if (PLATFORMS | index($k)) == null + then "\($nm): unknown platform \"\($k)\" in package_manager" else empty end), + + (["priority","optional","environment","install_command"][] as $f + | .[$f] as $v + | if ($v | type) == "object" + then (($v | keys[]) as $k + | if ((.package_manager | keys) | index($k)) == null + then "\($nm): \($f) keys platform \"\($k)\" not in package_manager" else empty end) + else empty end), + + ((.package_manager | keys[]) as $p + | (prfor($p) as $pr + | if (TIERS | index($pr)) == null + then "\($nm): priority for \"\($p)\" is \($pr | tojson) — must be high/medium/low/none" else empty end), + (optfor($p) as $o + | if ($o | type) != "boolean" + then "\($nm): optional for \"\($p)\" is \($o | tojson) — must be boolean" else empty end)), + + (.environment as $env + | if $env == null then empty + elif ($env | type) == "array" + then ($env[] as $x + | if (ENVS | index($x)) == null + then "\($nm): environment value \"\($x)\" — must be work/personal" else empty end) + elif ($env | type) == "object" + then (($env | to_entries[]) as $kv + | if ($kv.value | type) != "array" + then "\($nm): environment.\($kv.key) must be an array" + else ($kv.value[] as $x + | if (ENVS | index($x)) == null + then "\($nm): environment.\($kv.key) value \"\($x)\" — must be work/personal" else empty end) + end) + else "\($nm): environment must be a tag array or per-platform object" end), + + (if (.tags | type) != "array" then "\($nm): tags must be an array" + elif (.tags | length) == 0 then "\($nm): tags must be non-empty" + else (.tags[] as $tg + | if (TAGS | index($tg)) == null + then "\($nm): unknown tag \"\($tg)\" — not in the controlled vocabulary" else empty end) + end) + ] + | .[] +' "$PKG") + +if [[ -n "$errors" ]]; then + printf '%s\n' "$errors" >&2 + printf '\nvalidate-packages: FAILED\n' >&2 + exit 1 +fi + +printf 'validate-packages: PASSED (%d entries)\n' "$(jq length "$PKG")" From 97a3119eb9c9cc89028b7f92941b810dcb28f430 Mon Sep 17 00:00:00 2001 From: Ulises Chavarria Date: Tue, 23 Jun 2026 17:21:14 -0700 Subject: [PATCH 2/7] feat(setup): interactive category picker + --tags/--base selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bare 'setup.sh' on a TTY (no selection flag) now prompts the user to choose which categories to install; base essentials are always included. Adds a tagok() filter to the engine selection (CORE_JQ_DEFS) that reads TAG_FILTER_ACTIVE/SELECTED_TAGS from the environment — base (high-priority packages) and enabled work/personal apps bypass the filter, everything else must match a selected tag. New flags: --base (base only) and --tags (base + named categories) for non-interactive use. The prompt is skipped on the server profile and in non-interactive/CI runs; the filter is inactive by default so all flag-driven and CI runs are byte-identical (smoke gate unchanged: 79/31/19/39). Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 10 +++- lib/core.sh | 139 ++++++++++++++++++++++++++++++++++++++++++--- platforms/macos.sh | 4 +- setup.sh | 12 +++- 4 files changed, 151 insertions(+), 14 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a94a147..cadd752 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,8 +8,16 @@ tracked in TODO.md.) ## Entrypoints - `setup.sh` — installs everything for the detected platform. - Flags: `--optional --work --personal --dry-run --platform --profile `. + Flags: `--optional --work --personal --base --tags --dry-run --platform --profile `. The server platform is never auto-detected (`--profile server` or `--platform server` required). + Category selection: `--base` installs only the high-priority base set; `--tags + development,terminal` installs base + those `packages.json` tag categories + (enabled work/personal apps install regardless of category). Run bare on a TTY + with no selection flag and `core_maybe_prompt_selection` prompts interactively; + it is skipped on the server profile and in non-interactive/CI runs. The filter + is implemented by `tagok()` in `CORE_JQ_DEFS`, which reads `TAG_FILTER_ACTIVE` + and `SELECTED_TAGS` from the environment — inactive by default, so flag-driven + and CI runs are unchanged. - `verify.sh` — read-only health check mirroring `setup.sh`'s selection logic. Flags: `--optional --work --personal --all --platform ` (no `--dry-run`; `--platform server`/`--profile server` is rejected — nothing diff --git a/lib/core.sh b/lib/core.sh index 755c0cd..bc5cd27 100755 --- a/lib/core.sh +++ b/lib/core.sh @@ -21,12 +21,32 @@ INCLUDE_WORK=false INCLUDE_PERSONAL=false DRY_RUN=false +# Category selection. When TAG_FILTER_ACTIVE is "true" the engine installs only +# the base set (high-priority foundational packages), enabled work/personal +# apps, and packages whose tags intersect SELECTED_TAGS — see tagok() in +# CORE_JQ_DEFS, which reads both from the environment. SELECTION_EXPLICIT tracks +# whether the user passed any selection flag, so a bare interactive run knows to +# prompt instead. +SELECTION_EXPLICIT=false +TAG_FILTER_ACTIVE=false +SELECTED_TAGS="[]" +export TAG_FILTER_ACTIVE SELECTED_TAGS + core_parse_args() { while [[ $# -gt 0 ]]; do case "$1" in - --optional) INCLUDE_OPTIONAL=true ;; - --work) INCLUDE_WORK=true ;; - --personal) INCLUDE_PERSONAL=true ;; + --optional) INCLUDE_OPTIONAL=true; SELECTION_EXPLICIT=true ;; + --work) INCLUDE_WORK=true; SELECTION_EXPLICIT=true ;; + --personal) INCLUDE_PERSONAL=true; SELECTION_EXPLICIT=true ;; + --base) + TAG_FILTER_ACTIVE=true; SELECTED_TAGS="[]"; SELECTION_EXPLICIT=true ;; + --tags) + if [[ -z "${2:-}" || "${2:-}" == -* ]]; then + printf 'ERROR: --tags requires a comma-separated list (e.g. development,terminal).\n' >&2 + exit 1 + fi + SELECTED_TAGS="$(core_csv_to_json "$2")" + TAG_FILTER_ACTIVE=true; SELECTION_EXPLICIT=true; shift ;; --dry-run) DRY_RUN=true ;; --distro|--platform) if [[ -z "${2:-}" || "${2:-}" == -* ]]; then @@ -80,6 +100,100 @@ core_detect_platform() { if [[ "$PLATFORM" == "server" ]]; then SERVER_PROFILE=true; fi } +# ── Interactive category selection ──────────────────────────────────────────── + +# "dev, terminal ,local-llm" -> ["dev","terminal","local-llm"] (built without jq +# so --tags works before the jq bootstrap; tags are simple [a-z-] tokens). +core_csv_to_json() { + local csv="$1" json="[" first=1 tok + local IFS=',' + local -a arr + read -ra arr <<< "$csv" + for tok in "${arr[@]}"; do + tok="${tok#"${tok%%[![:space:]]*}"}" + tok="${tok%"${tok##*[![:space:]]}"}" + [[ -z "$tok" ]] && continue + [[ "$first" -eq 1 ]] && first=0 || json+="," + json+="\"$tok\"" + done + json+="]" + printf '%s' "$json" +} + +# Prompt the user to pick categories when setup is run bare on a terminal. +# Sets SELECTED_TAGS / TAG_FILTER_ACTIVE and the INCLUDE_* toggles in place. +core_prompt_selection() { + if ! command -v jq >/dev/null; then + printf ' (jq not found — skipping interactive selection; installing the full default set)\n' + return 0 + fi + local -a cats=() + local cat line tok num i col=0 json first=1 + while IFS= read -r cat; do cats+=("$cat"); done \ + < <(jq -r '[.[].tags[]] | unique | .[]' "$PACKAGES_JSON") + + printf '\nNo options given — choose what to install.\n' + printf 'Base essentials (shell, git, core tools) are always included.\n\n' + printf 'Categories:\n' + for i in "${!cats[@]}"; do + printf ' %2d) %-18s' "$((i + 1))" "${cats[$i]}" + col=$((col + 1)) + [[ $((col % 3)) -eq 0 ]] && printf '\n' + done + [[ $((col % 3)) -ne 0 ]] && printf '\n' + printf '\nExtras: w) work apps p) personal apps o) optional low-priority\n' + printf '\nEnter numbers/letters separated by spaces (e.g. "2 5 w"), "all", or Enter for base only: ' + + IFS= read -r line || line="" + + if [[ "$line" == "all" || "$line" == "a" ]]; then + TAG_FILTER_ACTIVE=false + INCLUDE_WORK=true; INCLUDE_PERSONAL=true; INCLUDE_OPTIONAL=true + else + json="[" + # shellcheck disable=SC2086 # intentional word-splitting on the input line + for tok in $line; do + case "$tok" in + w|W) INCLUDE_WORK=true ;; + p|P) INCLUDE_PERSONAL=true ;; + o|O) INCLUDE_OPTIONAL=true ;; + ''|*[!0-9]*) printf ' (ignoring "%s")\n' "$tok" >&2 ;; + *) + num=$((10#$tok)) + if [[ "$num" -ge 1 && "$num" -le "${#cats[@]}" ]]; then + [[ "$first" -eq 1 ]] && first=0 || json+="," + json+="\"${cats[$((num - 1))]}\"" + else + printf ' (ignoring out-of-range "%s")\n' "$tok" >&2 + fi ;; + esac + done + json+="]" + SELECTED_TAGS="$json" + TAG_FILTER_ACTIVE=true + fi + export TAG_FILTER_ACTIVE SELECTED_TAGS + + printf '\n==> Installing: base' + if [[ "$TAG_FILTER_ACTIVE" == true && "$SELECTED_TAGS" != "[]" ]]; then + printf ' + %s' "$(printf '%s' "$SELECTED_TAGS" | jq -r 'join(", ")')" + fi + [[ "$INCLUDE_WORK" == true ]] && printf ' + work' + [[ "$INCLUDE_PERSONAL" == true ]] && printf ' + personal' + [[ "$INCLUDE_OPTIONAL" == true ]] && printf ' + optional' + [[ "$TAG_FILTER_ACTIVE" != true ]] && printf ' (full set)' + printf '\n' +} + +# Prompt only when run interactively with no selection flags; never on the +# headless server profile or in non-interactive/CI contexts. +core_maybe_prompt_selection() { + [[ "$SERVER_PROFILE" == true ]] && return 0 + [[ "$SELECTION_EXPLICIT" == true ]] && return 0 + [[ -t 0 ]] || return 0 + core_prompt_selection +} + # ── Run helpers ─────────────────────────────────────────────────────────────── run() { @@ -158,6 +272,13 @@ def envok($plat; $w; $p): ($e == null) or (($w == "true") and ($e | index("work"))) or (($p == "true") and ($e | index("personal")))); +def tagok($plat): + if (env.TAG_FILTER_ACTIVE != "true") then true + elif (prfor($plat) == "high") then true + elif (envfor($plat) != null) then true + else ((env.SELECTED_TAGS // "[]" | fromjson) as $sel + | (.tags // []) | any(. as $t | ($sel | index($t)) != null)) + end; def icfor($plat): (.install_command | if type == "object" then .[$plat] else . end); def pname($plat): (.[$plat + "_name"] // .name); @@ -170,7 +291,7 @@ pkg_names() { jq -r --arg plat "$PLATFORM" --arg m "$manager" --arg pr "$priority" \ --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" \ "$CORE_JQ_DEFS"'[.[] | select( - .package_manager[$plat] == $m and prfor($plat) == $pr and envok($plat; $w; $p) + .package_manager[$plat] == $m and prfor($plat) == $pr and envok($plat; $w; $p) and tagok($plat) ) | pname($plat)] | join(" ")' "$PACKAGES_JSON" } @@ -191,7 +312,7 @@ print_custom_reminders() { --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" \ "$CORE_JQ_DEFS"'.[] | select( .package_manager[$plat] == "custom" and prfor($plat) == $pr and - (.handled_by_setup != true) and envok($plat; $w; $p) + (.handled_by_setup != true) and envok($plat; $w; $p) and tagok($plat) ) | " - \(.name)\n \(.description)\n Install: \(icfor($plat))"' \ "$PACKAGES_JSON") [[ -n "$items" ]] && printf '%s\n' "$items" @@ -228,7 +349,7 @@ pipx_install_tier() { jq -r --arg plat "$PLATFORM" --arg pr "$priority" \ --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" \ "$CORE_JQ_DEFS"'.[] | select( - .package_manager[$plat] == "pipx" and prfor($plat) == $pr and envok($plat; $w; $p) + .package_manager[$plat] == "pipx" and prfor($plat) == $pr and envok($plat; $w; $p) and tagok($plat) ) | (icfor($plat) // ("pipx install " + .name))' "$PACKAGES_JSON" | while read -r cmd; do run_eval "$cmd" @@ -241,7 +362,7 @@ pnpm_install_tier() { names=$(jq -r --arg plat "$PLATFORM" --arg pr "$priority" \ --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" \ "$CORE_JQ_DEFS"'[.[] | select( - .package_manager[$plat] == "pnpm" and prfor($plat) == $pr and envok($plat; $w; $p) + .package_manager[$plat] == "pnpm" and prfor($plat) == $pr and envok($plat; $w; $p) and tagok($plat) ) | .name] | join(" ")' "$PACKAGES_JSON") [[ -z "$names" ]] && return 0 # shellcheck disable=SC2086 @@ -386,7 +507,7 @@ snap_install_tier() { --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" \ "$CORE_JQ_DEFS"'[.[] | select( .package_manager[$plat] == "snap" and prfor($plat) == $pr and - envok($plat; $w; $p) and (icfor($plat) == null) + envok($plat; $w; $p) and (icfor($plat) == null) and tagok($plat) ) | pname($plat)] | join(" ")' "$PACKAGES_JSON") # Snaps needing flags (e.g. --classic) carry their full install_command. # shellcheck disable=SC2016 @@ -394,7 +515,7 @@ snap_install_tier() { --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" \ "$CORE_JQ_DEFS"'.[] | select( .package_manager[$plat] == "snap" and prfor($plat) == $pr and - envok($plat; $w; $p) and (icfor($plat) != null) + envok($plat; $w; $p) and (icfor($plat) != null) and tagok($plat) ) | "\(.name)|\(icfor($plat))"' "$PACKAGES_JSON") if [[ -n "$regular_snaps" ]]; then diff --git a/platforms/macos.sh b/platforms/macos.sh index e8c11a6..fb1cfe8 100755 --- a/platforms/macos.sh +++ b/platforms/macos.sh @@ -53,7 +53,7 @@ mac_custom_install_tier() { --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" \ "$CORE_JQ_DEFS"'.[] | select( .package_manager[$plat] == "custom" and prfor($plat) == $pr and - envok($plat; $w; $p) and (icfor($plat) != null) and .name != "nvm" + envok($plat; $w; $p) and (icfor($plat) != null) and .name != "nvm" and tagok($plat) ) | icfor($plat)' "$PACKAGES_JSON" | while read -r cmd; do run_eval "$cmd" @@ -75,7 +75,7 @@ print_app_store_reminders() { apps=$(jq -r --arg plat "$PLATFORM" --arg pr "$priority" \ --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" \ "$CORE_JQ_DEFS"'.[] | select( - .package_manager[$plat] == "app-store" and prfor($plat) == $pr and envok($plat; $w; $p) + .package_manager[$plat] == "app-store" and prfor($plat) == $pr and envok($plat; $w; $p) and tagok($plat) ) | " - \(.name): \(.description)"' "$PACKAGES_JSON") [[ -n "$apps" ]] && printf '%s\n' "$apps" return 0 diff --git a/setup.sh b/setup.sh index f4109d0..eacd741 100755 --- a/setup.sh +++ b/setup.sh @@ -1,11 +1,18 @@ #!/usr/bin/env bash # Unified setup entrypoint (UNIFICATION.md, issue #36). -# Usage: bash setup.sh [--optional] [--work] [--personal] [--dry-run] -# [--platform ] [--profile ] +# Usage: bash setup.sh [--optional] [--work] [--personal] [--base] [--tags ] +# [--dry-run] [--platform ] +# [--profile ] # --optional also install low-priority optional packages # --work also install work-only packages # --personal also install personal-only packages +# --base install only the base essentials (no category packages) +# --tags install base + only the named categories, e.g. +# --tags development,terminal (categories are packages.json tags) # --dry-run print all commands without executing anything +# +# Run bare on a terminal (no selection flags) and setup prompts you to pick +# categories interactively; base essentials are always installed. # --platform

force platform; default: auto-detect (uname / /etc/os-release). # --distro is accepted as an alias. # --profile server headless server profile (apt only, no GUI packages); @@ -23,6 +30,7 @@ source "$SETUP_ROOT/lib/core.sh" core_parse_args "$@" core_detect_platform +core_maybe_prompt_selection # shellcheck source=/dev/null source "$SETUP_ROOT/platforms/$PLATFORM.sh" From a23d70201e7f8b2f448035b83274fe32073a1796 Mon Sep 17 00:00:00 2001 From: Ulises Chavarria Date: Wed, 24 Jun 2026 02:15:30 -0700 Subject: [PATCH 3/7] fix(setup): bootstrap curl/wget/jq before the nvm/pyenv install scripts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a fresh Ubuntu desktop (no curl preinstalled) the nvm step — which pipes the curl installer into a shell via eval at the top of the Linux flow — failed under 'set -euo pipefail' and aborted the whole run before rust_toolchain_step and set_default_shell could execute. That is why the Rust default toolchain and the zsh login-shell switch never happened either; both steps run later in the flow. apt_bootstrap now installs ca-certificates plus any missing curl/wget/jq right after 'apt update', before any install script or apt-repo key fetch runs. wget is bootstrapped too since the eza/obs repo setup needs it. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/core.sh | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/core.sh b/lib/core.sh index bc5cd27..27e7261 100755 --- a/lib/core.sh +++ b/lib/core.sh @@ -441,9 +441,17 @@ apt_bootstrap() { printf '==> Updating package list...\n' run sudo apt update - if ! command -v jq &>/dev/null; then - printf '==> Bootstrapping jq...\n' - run sudo apt install -y jq + # Bootstrap the essentials the rest of setup relies on before the package + # tiers run: curl/wget drive the nvm/pyenv/tailscale/claude install scripts and + # the apt-repo key fetches below; jq drives package selection. Fresh Ubuntu + # desktop ships none of curl/jq, so the first curl step (nvm) used to fail. + local boot=() b + for b in curl wget jq; do + command -v "$b" &>/dev/null || boot+=("$b") + done + if [[ ${#boot[@]} -gt 0 ]]; then + printf '==> Bootstrapping essentials: %s\n' "${boot[*]}" + run sudo apt install -y ca-certificates "${boot[@]}" fi local need_update=false From 9d64ded7b78f7fb3da36ad6f1f278f3ea9900e7a Mon Sep 17 00:00:00 2001 From: Ulises Chavarria Date: Wed, 24 Jun 2026 02:34:32 -0700 Subject: [PATCH 4/7] harden(packages): validator robustness, merge jq, --tags input validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial review of the branch surfaced these (all medium/low): validator (scripts/validate-packages.sh): - detect duplicate package names (group_by .name) — this caught a real one: jq was still two entries, so merge it like the other per-platform duplicates (macOS brew low/optional, apt/yay medium/required). 97 -> 96 entries. - guard keys-walking on object type so a non-object/missing package_manager yields the clean diagnostic instead of crashing jq and masking other errors - flag empty {} package_manager (targets zero platforms) - flag missing/empty name - assert the document top-level is a JSON array selection (lib/core.sh, setup.sh): - --tags warns on tokens matching no category and on an empty result instead of silently degrading to base-only - apt_bootstrap prints its intent under --dry-run (command -v probes the host, so the bootstrap was invisible in dry-run/smoke) Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/core.sh | 21 ++++++++++++++++- packages.json | 16 ++++--------- scripts/validate-packages.sh | 45 +++++++++++++++++++++++++----------- setup.sh | 1 + 4 files changed, 56 insertions(+), 27 deletions(-) diff --git a/lib/core.sh b/lib/core.sh index 27e7261..7113a6a 100755 --- a/lib/core.sh +++ b/lib/core.sh @@ -46,6 +46,8 @@ core_parse_args() { exit 1 fi SELECTED_TAGS="$(core_csv_to_json "$2")" + [[ "$SELECTED_TAGS" == "[]" ]] && \ + printf 'WARNING: --tags resolved to no categories — installing base only.\n' >&2 TAG_FILTER_ACTIVE=true; SELECTION_EXPLICIT=true; shift ;; --dry-run) DRY_RUN=true ;; --distro|--platform) @@ -185,6 +187,21 @@ core_prompt_selection() { printf '\n' } +# Warn (don't fail) when --tags names a category that matches no package — tags +# are metadata, so a typo would otherwise silently install only the base set. +core_validate_tag_selection() { + [[ "$TAG_FILTER_ACTIVE" == true ]] || return 0 + [[ "$SELECTED_TAGS" == "[]" ]] && return 0 + command -v jq >/dev/null || return 0 + local vocab tag + vocab="$(jq -r '[.[].tags[]] | unique | .[]' "$PACKAGES_JSON" 2>/dev/null)" || return 0 + while IFS= read -r tag; do + [[ -z "$tag" ]] && continue + grep -qxF -- "$tag" <<< "$vocab" \ + || printf 'WARNING: --tags category "%s" matches no packages (ignored).\n' "$tag" >&2 + done < <(printf '%s' "$SELECTED_TAGS" | jq -r '.[]') +} + # Prompt only when run interactively with no selection flags; never on the # headless server profile or in non-interactive/CI contexts. core_maybe_prompt_selection() { @@ -449,7 +466,9 @@ apt_bootstrap() { for b in curl wget jq; do command -v "$b" &>/dev/null || boot+=("$b") done - if [[ ${#boot[@]} -gt 0 ]]; then + if [[ "$DRY_RUN" == true ]]; then + printf ' [dry-run] ensure ca-certificates curl wget jq are present\n' + elif [[ ${#boot[@]} -gt 0 ]]; then printf '==> Bootstrapping essentials: %s\n' "${boot[*]}" run sudo apt install -y ca-certificates "${boot[@]}" fi diff --git a/packages.json b/packages.json index 0b89102..bf5b70b 100644 --- a/packages.json +++ b/packages.json @@ -363,18 +363,10 @@ { "name": "jq", "tags": ["development"], - "package_manager": { "ubuntu": "apt", "arch": "yay", "server": "apt" }, - "priority": "medium", - "optional": false, - "description": "JSON processor — required by setup.sh to parse this file" - }, - { - "name": "jq", - "tags": ["development"], - "package_manager": { "macos": "brew" }, - "priority": "low", - "optional": true, - "description": "JSON processor — system-provided on macOS; brew version only needed if the system one is missing or outdated" + "package_manager": { "macos": "brew", "ubuntu": "apt", "arch": "yay", "server": "apt" }, + "priority": { "macos": "low", "ubuntu": "medium", "arch": "medium", "server": "medium" }, + "optional": { "macos": true, "ubuntu": false, "arch": false, "server": false }, + "description": "JSON processor — required by setup.sh to parse this file; system-provided on macOS so the brew build is low-priority/optional there" }, { "name": "curl", diff --git a/scripts/validate-packages.sh b/scripts/validate-packages.sh index 473dd3f..01d5587 100755 --- a/scripts/validate-packages.sh +++ b/scripts/validate-packages.sh @@ -29,6 +29,11 @@ if ! jq empty "$PKG" 2>/dev/null; then exit 1 fi +if ! jq -e 'type == "array"' "$PKG" >/dev/null 2>&1; then + printf 'error: %s top-level value must be a JSON array\n' "$PKG" >&2 + exit 1 +fi + # shellcheck disable=SC2016 # $vars below are jq variables, not shell expansions errors=$(jq -r ' def PLATFORMS: ["macos","ubuntu","arch","server"]; @@ -41,33 +46,45 @@ errors=$(jq -r ' def prfor($p): (.priority | if type == "object" then .[$p] else . end); def optfor($p): (.optional | if type == "object" then .[$p] else . end); - to_entries[] + ( group_by(.name)[] | select(length > 1) + | "duplicate name \"\(.[0].name // "(unnamed)")\" — \(length) entries" ), + ( to_entries[] | .key as $i | .value as $e | ($e.name // "") as $nm | $e | [ + (if (.name | type) != "string" or (.name | length) == 0 + then ": name must be a non-empty string" else empty end), + (if (.package_manager | type) != "object" - then "\($nm): package_manager must be an object" else empty end), + then "\($nm): package_manager must be an object" + elif (.package_manager | length) == 0 + then "\($nm): package_manager must list at least one platform" + else empty end), - ((.package_manager | keys[]) as $k - | if (PLATFORMS | index($k)) == null - then "\($nm): unknown platform \"\($k)\" in package_manager" else empty end), + (if (.package_manager | type) == "object" + then ((.package_manager | keys[]) as $k + | if (PLATFORMS | index($k)) == null + then "\($nm): unknown platform \"\($k)\" in package_manager" else empty end) + else empty end), (["priority","optional","environment","install_command"][] as $f | .[$f] as $v - | if ($v | type) == "object" + | if ($v | type) == "object" and (.package_manager | type) == "object" then (($v | keys[]) as $k | if ((.package_manager | keys) | index($k)) == null then "\($nm): \($f) keys platform \"\($k)\" not in package_manager" else empty end) else empty end), - ((.package_manager | keys[]) as $p - | (prfor($p) as $pr - | if (TIERS | index($pr)) == null - then "\($nm): priority for \"\($p)\" is \($pr | tojson) — must be high/medium/low/none" else empty end), - (optfor($p) as $o - | if ($o | type) != "boolean" - then "\($nm): optional for \"\($p)\" is \($o | tojson) — must be boolean" else empty end)), + (if (.package_manager | type) == "object" + then ((.package_manager | keys[]) as $p + | (prfor($p) as $pr + | if (TIERS | index($pr)) == null + then "\($nm): priority for \"\($p)\" is \($pr | tojson) — must be high/medium/low/none" else empty end), + (optfor($p) as $o + | if ($o | type) != "boolean" + then "\($nm): optional for \"\($p)\" is \($o | tojson) — must be boolean" else empty end)) + else empty end), (.environment as $env | if $env == null then empty @@ -92,7 +109,7 @@ errors=$(jq -r ' then "\($nm): unknown tag \"\($tg)\" — not in the controlled vocabulary" else empty end) end) ] - | .[] + | .[] ) ' "$PKG") if [[ -n "$errors" ]]; then diff --git a/setup.sh b/setup.sh index eacd741..1a47b51 100755 --- a/setup.sh +++ b/setup.sh @@ -30,6 +30,7 @@ source "$SETUP_ROOT/lib/core.sh" core_parse_args "$@" core_detect_platform +core_validate_tag_selection core_maybe_prompt_selection # shellcheck source=/dev/null From 83e96430ec3c22733b052899d082d6e2b01b5813 Mon Sep 17 00:00:00 2001 From: Ulises Chavarria Date: Wed, 24 Jun 2026 02:40:21 -0700 Subject: [PATCH 5/7] feat(setup): gate tailscale/claude-code/docker steps by category selection These handled_by_setup custom packages ran as unconditional steps, so --base and --tags installed them regardless of the picker (e.g. --tags development still pulled in tailscale + claude-code). Add a pkg_selected predicate that mirrors the tier/envok/tagok selection and guard each step with it: --base -> none of the three --tags networking -> tailscale; --tags ai-coding -> claude-code --tags containers --optional -> docker tailscale/claude-code gate inside their step functions; the desktop docker call gates at its (already --optional) call site. The server profile keeps the filter inactive, so all three still install there (server docker stays unconditional, since docker has no server entry in packages.json and that path is essential). Addresses the low-severity tag-bypass findings from the branch review. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 4 +++- lib/core.sh | 22 +++++++++++++++++++++- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cadd752..717c4d0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -17,7 +17,9 @@ tracked in TODO.md.) it is skipped on the server profile and in non-interactive/CI runs. The filter is implemented by `tagok()` in `CORE_JQ_DEFS`, which reads `TAG_FILTER_ACTIVE` and `SELECTED_TAGS` from the environment — inactive by default, so flag-driven - and CI runs are unchanged. + and CI runs are unchanged. The dedicated custom-install steps (tailscale, + claude-code, docker) gate on `pkg_selected` so they honor the selection too; + the server profile keeps the filter inactive, so they install as before there. - `verify.sh` — read-only health check mirroring `setup.sh`'s selection logic. Flags: `--optional --work --personal --all --platform ` (no `--dry-run`; `--platform server`/`--profile server` is rejected — nothing diff --git a/lib/core.sh b/lib/core.sh index 7113a6a..05c33b1 100755 --- a/lib/core.sh +++ b/lib/core.sh @@ -312,6 +312,24 @@ pkg_names() { ) | pname($plat)] | join(" ")' "$PACKAGES_JSON" } +# True (exit 0) when the named package is selected for the current platform +# under the active flags and tag filter — mirrors the tier/envok/tagok logic the +# install loops use (high/medium always, low only with --optional, none never). +# Lets the dedicated custom-install steps (tailscale, claude-code, docker) honor +# --base/--tags instead of always running. On the server profile the tag filter +# is inactive, so these resolve exactly as before. +pkg_selected() { + # shellcheck disable=SC2016 + jq -e --arg plat "$PLATFORM" --arg n "$1" \ + --arg w "$INCLUDE_WORK" --arg p "$INCLUDE_PERSONAL" --arg opt "$INCLUDE_OPTIONAL" \ + "$CORE_JQ_DEFS"'any(.[]; + .name == $n and .package_manager[$plat] != null + and envok($plat; $w; $p) and tagok($plat) + and (prfor($plat) as $pr + | $pr == "high" or $pr == "medium" or ($pr == "low" and $opt == "true")))' \ + "$PACKAGES_JSON" >/dev/null 2>&1 +} + # install_command of a single package for this platform. custom_cmd() { # shellcheck disable=SC2016 @@ -596,6 +614,7 @@ platform_install_tier() { # Tailscale via the official curl installer — apt-family default (ubuntu + server). # arch.sh overrides this (tailscale ships in the yay batch; just enable the daemon). platform_tailscale_step() { + pkg_selected tailscale || return 0 printf '\n' if [[ "$DRY_RUN" == true ]]; then command -v tailscale &>/dev/null \ @@ -727,6 +746,7 @@ rust_toolchain_step() { } claude_code_step() { + pkg_selected claude-code || return 0 printf '\n' if [[ "$DRY_RUN" == true ]]; then command -v claude &>/dev/null \ @@ -885,7 +905,7 @@ linux_main() { platform_install_tier low pipx_install_tier low pnpm_install_tier low - platform_docker_optional + if pkg_selected docker; then platform_docker_optional; fi fi custom_reminders_section From 56e1c6d9b06ed71d3ca8d3345872fde059fb5ac1 Mon Sep 17 00:00:00 2001 From: Ulises Chavarria Date: Wed, 24 Jun 2026 02:45:26 -0700 Subject: [PATCH 6/7] refactor(setup): make docker install_command the source of truth platform_docker_optional hardcoded the same get.docker.com command that docker's packages.json install_command already held, leaving the field dead and misleading. Add the missing "server":"custom" key (the server installs docker but had no entry for it) and read the command via custom_cmd docker, mirroring tailscale/pyenv/nvm/claude-code. The post-install group step stays in code since no install_command can express it. Resolves the docker dead-data follow-up. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/core.sh | 8 +++++--- packages.json | 2 +- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/core.sh b/lib/core.sh index 05c33b1..877f4df 100755 --- a/lib/core.sh +++ b/lib/core.sh @@ -628,12 +628,14 @@ platform_tailscale_step() { fi } -# Docker via get.docker.com — apt-family default (ubuntu + server). -# arch.sh overrides this (docker ships in the yay batch; just enable the daemon). +# Docker — apt-family default (ubuntu + server); the install command comes from +# packages.json (docker's install_command) via custom_cmd, the post-install +# group step lives here. arch.sh overrides this (docker ships in the yay batch; +# just enable the daemon). platform_docker_optional() { if ! command -v docker &>/dev/null; then printf '==> Installing Docker...\n' - run_eval "curl -fsSL https://get.docker.com | sudo sh" + run_eval "$(custom_cmd docker)" run sudo usermod -aG docker "$USER" printf ' Log out and back in for the docker group to take effect.\n' else diff --git a/packages.json b/packages.json index bf5b70b..c8bd271 100644 --- a/packages.json +++ b/packages.json @@ -423,7 +423,7 @@ { "name": "docker", "tags": ["development", "containers"], - "package_manager": { "macos": "brew-cask", "ubuntu": "custom", "arch": "yay" }, + "package_manager": { "macos": "brew-cask", "ubuntu": "custom", "arch": "yay", "server": "custom" }, "macos_name": "docker-desktop", "priority": "low", "optional": true, From aad170189d73ca50fef7cda73dc3fe22de09971b Mon Sep 17 00:00:00 2001 From: Ulises Chavarria Date: Wed, 24 Jun 2026 03:16:16 -0700 Subject: [PATCH 7/7] refactor(setup): extract core_tag_vocabulary helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tag-vocabulary jq query was duplicated in core_prompt_selection and core_validate_tag_selection; extract a single core_tag_vocabulary helper both call. Cosmetic DRY cleanup from the max-effort review — no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/core.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/core.sh b/lib/core.sh index 877f4df..6aa19a0 100755 --- a/lib/core.sh +++ b/lib/core.sh @@ -104,6 +104,11 @@ core_detect_platform() { # ── Interactive category selection ──────────────────────────────────────────── +# Newline-separated list of every tag category present in packages.json. +core_tag_vocabulary() { + jq -r '[.[].tags[]] | unique | .[]' "$PACKAGES_JSON" +} + # "dev, terminal ,local-llm" -> ["dev","terminal","local-llm"] (built without jq # so --tags works before the jq bootstrap; tags are simple [a-z-] tokens). core_csv_to_json() { @@ -132,7 +137,7 @@ core_prompt_selection() { local -a cats=() local cat line tok num i col=0 json first=1 while IFS= read -r cat; do cats+=("$cat"); done \ - < <(jq -r '[.[].tags[]] | unique | .[]' "$PACKAGES_JSON") + < <(core_tag_vocabulary) printf '\nNo options given — choose what to install.\n' printf 'Base essentials (shell, git, core tools) are always included.\n\n' @@ -194,7 +199,7 @@ core_validate_tag_selection() { [[ "$SELECTED_TAGS" == "[]" ]] && return 0 command -v jq >/dev/null || return 0 local vocab tag - vocab="$(jq -r '[.[].tags[]] | unique | .[]' "$PACKAGES_JSON" 2>/dev/null)" || return 0 + vocab="$(core_tag_vocabulary 2>/dev/null)" || return 0 while IFS= read -r tag; do [[ -z "$tag" ]] && continue grep -qxF -- "$tag" <<< "$vocab" \