diff --git a/.shellcheckrc b/.shellcheckrc new file mode 100644 index 0000000..a0d911c --- /dev/null +++ b/.shellcheckrc @@ -0,0 +1,20 @@ +# .shellcheckrc — repo-wide ShellCheck config so author-time, CI, and editor +# (bashls / nvim) all lint identically. Previously the only ShellCheck settings were +# the `-x` flag the audit passes and per-file `# shellcheck` directives scattered in +# the scripts; an editor opening a script saw none of that and could disagree with CI. +# This centralises the cross-cutting bits. File-SPECIFIC suppressions (e.g. the +# intentional SC2016 in test-core.sh / bench-core.sh, where `$` in single quotes is +# expanded by a zsh CHILD, not this bash parent) deliberately stay inline, next to the +# code that needs them — they are not global truths and must not be hidden here. + +# Follow `source`/`.`-d files so `-x` works from any working directory, and resolve a +# sourced path relative to the script being checked (SCRIPTDIR) — this is what lets the +# gate scripts' `source "${BASH_SOURCE[0]%/*}/lib/common.sh"` resolve in an editor +# without each file needing an inline `# shellcheck source=` directive. +external-sources=true +source-path=SCRIPTDIR +source-path=scripts + +# Lint as bash: every runnable script here is `#!/usr/bin/env bash`, and the sourced +# libraries (scripts/lib/*.sh) carry no shebang, so pin the dialect explicitly. +shell=bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 36b7ec6..d7af0d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,23 @@ commit (`git tag -a vX.Y.Z -m vX.Y.Z`). ### Added +- `core.version` — a human-readable SemVer stamp vendored into every OS repo, plus a + `core-version` verb that reads it, so you can tell WHICH Core a given OS repo carries + from inside it (the subtree squash records the commit; this records the version). + `scripts/sync-core.sh` prints it on fan-out and the audit asserts it is well-formed. +- `core-doctor` — the shell counterpart to nvim's `:checkhealth gerrrt`: a scannable + report of which modern-CLI tools Core detected on this box and which integrations are + live, including the RESOLVED binary names (`fd`/`fdfind`, `bat`/`batcat`) and the + detected package manager. Read-only. +- `up -n`/`--dry-run` — list the packages that WOULD upgrade and exit, touching nothing + (the non-destructive inspect the count-only nudge didn't offer). +- `make audit-changed` (`audit-core.sh --changed`) — scope the audit to what your local + git diff touches, via the SAME `scripts/ci-classify.sh` CI uses; fails safe to the + full run when the diff can't be resolved. +- First-party completions for `fif`, `fbr`, `core-version`, and `core-doctor`, and a + `core.version`/`up --dry-run`-aware `_up`; the completion-parity test now covers them. +- `.shellcheckrc` — repo-wide ShellCheck config (`external-sources`, `source-path`, + `shell=bash`) so author-time, CI, and editor lint identically. - `zsh/ui.zsh` — shared terminal-UX primitives (`_core_err`/`_core_warn`/`_core_ok`/ `_core_hint`/`_core_usage`/`_core_confirm`/`_core_spin`), gum-aware with a plain fallback on every helper. Loads right after `tools` in the canonical chain and is @@ -78,6 +95,8 @@ commit (`git tag -a vX.Y.Z -m vX.Y.Z`). ### Changed +- `scripts/setup.sh` provisions `luacheck` via `luarocks` (no clean mise source) and + emits precise, actionable install hints — closing the last manual onboarding gap. - Defensive confirms on impactful interactive actions: `please` now previews the exact `sudo …` line and confirms before eval'ing it as root (and refuses with no previous command); `up` pre-confirms `Apply updates with ?` before touching the system @@ -111,8 +130,15 @@ commit (`git tag -a vX.Y.Z -m vX.Y.Z`). upstream breaking change — or a compromised tag — out to all nine machines on the next install; installs now fetch exactly the pinned commit. - ### Fixed +### Fixed +- fzf / fzf-tab previews hardcoded `bat`/`eza`, so every preview pane printed + "command not found" on Debian/Ubuntu (bat ships as `batcat`) and on any box without + eza. Previews now resolve `$BAT_BIN` with a `cat`/`ls` fallback, and a new audit + section (`fzf preview binary resolution`) locks it so the regression can't recur. +- `fif`, `fbr`, and the Alt-Z zoxide-jump widget assumed `fzf`/`rg`/`git`/`zoxide` + were present; they now degrade in Core's voice (`_core_err`/`_core_hint`) like `fcd`, + instead of a raw "command not found". - Removed leaked ``/`` template artifacts from the end of this changelog — the exact bug class the new markdown gate now catches. - Restored non-executable mode (`100644`) on the twelve `zsh/*.zsh` modules. They diff --git a/Makefile b/Makefile index 284ea56..61cda8c 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ # pre-commit call the same scripts/audit-core.sh, so `make audit` == green CI. # ────────────────────────────────────────────────────────────────────────────── .DEFAULT_GOAL := help -.PHONY: help setup audit test bench lint sync sync-dry hooks update-hooks update-plugins update-nvim-plugins check-pins +.PHONY: help setup audit audit-changed test bench lint sync sync-dry hooks update-hooks update-plugins update-nvim-plugins check-pins help: ## Show this help @echo "dotfiles-core — make targets:" @@ -20,6 +20,9 @@ setup: ## One-command dev bootstrap (pre-commit hooks + version doctor + audit) audit: ## Run the full Core audit (manifest, exec-bits, syntax, lint, behavioral) — the one gate @./scripts/audit-core.sh +audit-changed: ## Audit only what your git diff touches (fast dev loop; same classifier as CI) + @./scripts/audit-core.sh --changed + test: ## Run only the behavioral tests (load-order smoke + function units) @./scripts/test-core.sh diff --git a/README.md b/README.md index 6573bb3..e40da3e 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ git/ local.gitconfig.example identity template — seeded by bootstrap, never tracked nvim/ entire lazy.nvim tree: lua/gerrrt/{config,plugins,servers,utils} core.manifest the canonical list of Core files (drives sync + audits) +core.version human-readable Core version stamp (read by `core-version`) ``` > Load order is load-bearing: `tools` inits atuin (registers its widget), `options` diff --git a/core.manifest b/core.manifest index f0c4b09..633d21a 100644 --- a/core.manifest +++ b/core.manifest @@ -4,6 +4,10 @@ # OS repo (OS-native) or to dotfiles-Kali (offensive). # # This list is the contract. Audit scripts and the promotion checklist read it. +# ── version stamp — the human-readable Core version, vendored so an OS repo can +# report WHICH Core it carries (`core-version`); the subtree squash records the +# commit, this records the SemVer. ────────────────────────────────────────── +core.version # ── zsh ─────────────────────────────────────────────────────────────────────── # Load order (sourced by each OS repo's .zshrc loader, in THIS order): # tools -> ui -> options -> history -> aliases -> git -> functions -> fzf diff --git a/core.version b/core.version new file mode 100644 index 0000000..0d4d124 --- /dev/null +++ b/core.version @@ -0,0 +1 @@ +0.1.0-dev diff --git a/scripts/audit-core.sh b/scripts/audit-core.sh index 04559ca..f7678fd 100755 --- a/scripts/audit-core.sh +++ b/scripts/audit-core.sh @@ -41,6 +41,8 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$HERE" || exit 1 QUIET=0 +CHANGED=0 # --changed: derive the scope from the local git diff (fast dev loop) +SCOPE_EXPLICIT=0 # an explicit --scope always wins over --changed # Scope gates the SLOW, area-specific sections so a per-area push (driven by # scripts/ci-classify.sh) pays only for what it changed — e.g. a docs-only PR runs # the cheap structural/config/markdown checks but skips the zsh and nvim toolchains. @@ -106,11 +108,16 @@ while (($#)); do fi shift _set_scope "$1" + SCOPE_EXPLICIT=1 ;; - --scope=*) _set_scope "${1#*=}" ;; + --scope=*) + _set_scope "${1#*=}" + SCOPE_EXPLICIT=1 + ;; + --changed) CHANGED=1 ;; -h | --help) cat <<'EOF' -usage: audit-core.sh [-q|--quiet] [--scope LIST] [-h|--help] +usage: audit-core.sh [-q|--quiet] [--scope LIST] [--changed] [-h|--help] THE audit button — manifest/exec-bit/syntax/lint/config/markdown/workflow/ version/behavioral checks. CI and pre-commit run this exact script. @@ -120,6 +127,12 @@ version/behavioral checks. CI and pre-commit run this exact script. shell, nvim, all (default), none. Cheap structural/config/ markdown/workflow/version checks always run. CI sets this from scripts/ci-classify.sh; omit it locally to run the full audit. + --changed derive the scope from your local git diff (working tree vs HEAD, + falling back to the branch delta vs the default branch) using the + SAME scripts/ci-classify.sh CI uses — so a docs- or nvim-only edit + skips the gates it can't affect, tightening the dev loop. Fails SAFE + to the full run when the diff can't be resolved. An explicit --scope + overrides this. -h, --help show this help and exit EOF exit 0 @@ -138,6 +151,49 @@ done # shellcheck source=scripts/lib/common.sh source "${BASH_SOURCE[0]%/*}/lib/common.sh" +# ── --changed: derive the scope from the local git diff ─────────────────────── +# Reuse the EXACT classifier CI runs (scripts/ci-classify.sh) so `make audit-changed` +# narrows to the same gates a push would — one definition of path→gate, no drift. The +# changed set is the working tree vs HEAD plus untracked files; when the tree is clean +# we fall back to the branch delta vs the default branch. Anything unresolvable → the +# full run (fail-safe), matching CI's "detection miss never hides a gate" rule. An +# explicit --scope already set SCOPE_EXPLICIT and wins. +_changed_scope() { + if ! have git || ! git rev-parse --git-dir >/dev/null 2>&1; then + printf 'all' + return + fi + local files base + files="$( { git diff --name-only HEAD 2>/dev/null; git ls-files --others --exclude-standard 2>/dev/null; } | sort -u )" + if [[ -z "$files" ]]; then + for base in origin/main main origin/master master; do + git rev-parse -q --verify "$base" >/dev/null 2>&1 || continue + files="$(git diff --name-only "$base"...HEAD 2>/dev/null)" + break + done + fi + [[ -n "$files" ]] || { printf 'all'; return; } # nothing resolvable → full (safe) + local out sh nv scope="" + out="$(printf '%s\n' "$files" | "$HERE/scripts/ci-classify.sh" 2>/dev/null)" + sh="$(printf '%s\n' "$out" | sed -n 's/^shell=//p')" + nv="$(printf '%s\n' "$out" | sed -n 's/^nvim=//p')" + # The classifier must emit BOTH keys as true/false. If it errored (non-executable, + # internal failure) or produced no/garbage output, sh/nv won't be valid — fail SAFE + # to the full run rather than silently returning "none" and skipping every slow gate. + if { [[ "$sh" != true && "$sh" != false ]]; } || { [[ "$nv" != true && "$nv" != false ]]; }; then + printf 'all' + return + fi + [[ "$sh" == true ]] && scope="shell" + [[ "$nv" == true ]] && scope="${scope:+$scope,}nvim" + printf '%s' "${scope:-none}" +} +if ((CHANGED)) && ((!SCOPE_EXPLICIT)); then + _cs="$(_changed_scope)" + ((QUIET)) || printf '%s== --changed → scope %s ==%s\n' "$c_blu" "$_cs" "$c_rst" + _set_scope "$_cs" +fi + # Wall-clock from here, surfaced in the summary — so a long run (the headless nvim / # zsh legs) reads as "took Ns", not "hung", and a regression in audit cost is visible. SECONDS=0 @@ -147,7 +203,7 @@ SECONDS=0 # manifest, must appear here (or under a META_PREFIXES dir) or section 1 flags it. META_ALLOWLIST=( README.md PORTING-MATRIX.md CONTRIBUTING.md CHANGELOG.md LICENSE SECURITY.md - core.manifest .gitignore .gitattributes .editorconfig .pre-commit-config.yaml .markdownlint.jsonc + core.manifest .gitignore .gitattributes .editorconfig .pre-commit-config.yaml .markdownlint.jsonc .shellcheckrc Makefile nvim/.luacheckrc CODEOWNERS pull_request_template.md @@ -282,6 +338,34 @@ else skip "shellcheck (not installed)" fi +# ── 5b. fzf preview binary resolution (regression gate) ────────────────────── +# fzf / fzf-tab previews run their command STRING in a subshell, so a LITERAL `bat` +# there printed "command not found" in every preview pane on Debian/Ubuntu — those +# distros ship bat as `batcat` — a silent breakage that fanned out to those OS repos +# with no failing gate. The fix routes previews through $BAT_BIN (tools.zsh resolves +# the real name) with a cat/ls fallback. Lock it so the bug can't recur: no uncommented +# preview line in zsh/fzf.zsh or zsh/plugins.zsh may invoke a literal bat/batcat, and +# fzf.zsh must still reference $BAT_BIN. Pure sed+grep (busybox-safe), shell-scoped. +hdr "fzf preview binary resolution" +if ((SCOPE_SHELL)); then + pv_fail=0 + for f in zsh/fzf.zsh zsh/plugins.zsh; do + # Strip comments (from the first #), then flag a bare lowercase bat/batcat command + # token — $BAT_BIN (uppercase) is intentionally NOT matched, which is the point. + if sed 's/#.*//' "$f" | grep -qE '(^|[^A-Za-z_$])bat(cat)?[[:space:]]'; then + pv_fail=1 + fail "literal bat/batcat in a preview command ($f) — route it through \$BAT_BIN" + fi + done + grep -q 'BAT_BIN' zsh/fzf.zsh || { + pv_fail=1 + fail "zsh/fzf.zsh no longer references \$BAT_BIN (preview resolution lost)" + } + ((pv_fail)) || pass "fzf/fzf-tab previews resolve \$BAT_BIN (no literal bat/batcat)" +else + skip "fzf preview resolution (out of scope)" +fi + # ── 6. config files (toml / yaml parse) ────────────────────────────────────── # A malformed starship.toml / mise config.toml / ci.yml is still valid *text* — # so zsh -n and shellcheck never look at it — yet it breaks every one of the 9 @@ -414,6 +498,21 @@ else skip "version consistency ($VERSIONS_ENV or $PRECOMMIT_CFG unreadable)" fi +# core.version is the human-readable Core stamp vendored into all 9 OS repos (read by +# the `core-version` verb). A missing or malformed stamp would fan out a bogus version +# everywhere, so assert it exists and is SemVer-shaped (MAJOR.MINOR.PATCH, optional +# -prerelease). Single line only — the verb and sync-core.sh both read it whole. +if [[ -r core.version ]]; then + cv="$(tr -d '[:space:]' /dev/null | head -n1; } +# Dim follow-up "→" hint line (the fix-it, after a skip/warn) — one consistent voice +# for the actionable next step, mirroring zsh/ui.zsh's _core_hint in the runtime layer. +hint() { printf '%s→%s %s\n' "$c_yel" "$c_rst" "$*"; } + # ── 1. pre-commit hooks ─────────────────────────────────────────────────────── hdr "pre-commit hooks" PRECOMMIT_VERSION="$(_ver PRECOMMIT_VERSION)" @@ -46,8 +50,8 @@ fi # mise.local.toml from it and let `mise install` provision the linters the audit/CI # run. Best-effort + graceful like every other step: no mise → a hint, not a hard # stop; a tool mise can't supply just stays missing and the doctor below flags it. -# luacheck has no clean mise source (luarocks) so it stays manual; pre-commit is -# handled in step 1. The generated file is regenerated every run (idempotent) and is +# luacheck has no clean mise source (luarocks) so step 2b below provisions it; pre-commit +# is handled in step 1. The generated file is regenerated every run (idempotent) and is # NOT tracked — bumping a pin in tool-versions.env and re-running propagates here. hdr "hermetic dev toolchain (mise)" if have mise; then @@ -73,6 +77,31 @@ else skip "mise absent — install it (https://mise.jdx.dev) to auto-provision the pinned toolchain; the doctor below lists what to install manually" fi +# ── 2b. luacheck via luarocks (no clean mise source) ────────────────────────── +# luacheck is a luarocks package — mise has no first-class source for it, so step 2 +# can't provision it and it used to be left wholly manual ("see ci.yml"). Close that +# gap the same way CI does: when it's missing AND luarocks is present, install the +# pinned version with --local (lands in ~/.luarocks/bin). Best-effort + graceful: no +# luarocks → a precise hint, not a hard stop; the doctor below still reports the result. +hdr "luacheck (luarocks)" +LUACHECK_VERSION="$(_ver LUACHECK_VERSION)" +if have luacheck; then + pass "luacheck already installed" +elif have luarocks; then + if luarocks --local install luacheck "$LUACHECK_VERSION" >/dev/null 2>&1; then + pass "luacheck ${LUACHECK_VERSION} installed via luarocks (--local)" + # ~/.luarocks/bin is where --local drops the binary; surface it if it's not on PATH. + case ":$PATH:" in + *":$HOME/.luarocks/bin:"*) ;; + *) hint "add \$HOME/.luarocks/bin to PATH so luacheck resolves: export PATH=\"\$HOME/.luarocks/bin:\$PATH\"" ;; + esac + else + hint "luarocks present but 'luarocks --local install luacheck ${LUACHECK_VERSION}' failed — install lua + a dev toolchain, then retry" + fi +else + skip "luacheck absent and luarocks not found — install luarocks, then: luarocks --local install luacheck ${LUACHECK_VERSION}" +fi + # ── 3. version doctor (present? vs the pin) ─────────────────────────────────── hdr "tool versions (pinned in $VERSIONS)" _doctor() { # _doctor diff --git a/scripts/sync-core.sh b/scripts/sync-core.sh index 7dbab0e..0443fb9 100755 --- a/scripts/sync-core.sh +++ b/scripts/sync-core.sh @@ -86,8 +86,15 @@ err() { fail "$@"; } CORE_SHA="$(git ls-remote "$CORE_REMOTE" "$CORE_BRANCH" 2>/dev/null | awk 'NR==1{print substr($1,1,12)}')" [[ -n "$CORE_SHA" ]] || CORE_SHA="$(git -C "$HERE" rev-parse --short=12 "$CORE_BRANCH" 2>/dev/null || echo unknown)" -echo ":: core remote = $CORE_REMOTE (branch $CORE_BRANCH @ $CORE_SHA)" -echo ":: repos root = $REPOS_ROOT" +# Human-readable version stamp (core.version) — vendored into each OS repo so its +# `core-version` verb can report which Core it carries. Surfaced here too so the +# fan-out log records BOTH the SemVer and the commit that landed. +CORE_VERSION="$(tr -d '[:space:]' <"$HERE/core.version" 2>/dev/null || echo unknown)" +[[ -n "$CORE_VERSION" ]] || CORE_VERSION=unknown + +echo ":: core version = $CORE_VERSION" +echo ":: core remote = $CORE_REMOTE (branch $CORE_BRANCH @ $CORE_SHA)" +echo ":: repos root = $REPOS_ROOT" echo # ── Pre-fan-out gate: Core must be audit-green, and what you audited must be what diff --git a/scripts/test-core.sh b/scripts/test-core.sh index 8978fd1..860ec4e 100755 --- a/scripts/test-core.sh +++ b/scripts/test-core.sh @@ -426,6 +426,7 @@ _classify_is "zsh/ change → shell gate only" 'zsh/ui.zsh' true false _classify_is "nvim/ change → nvim gate only" 'nvim/init.lua' false true _classify_is "docs (*.md) change → no gate" 'README.md' false false _classify_is "infra (scripts/) change → full run" 'scripts/audit-core.sh' true true +_classify_is "infra (.shellcheckrc) change → full run" '.shellcheckrc' true true _classify_is "__ALL__ sentinel → full run" '__ALL__' true true _classify_is "unrecognised path → FAIL CLOSED to full run" 'newdir/thing.xyz' true true _classify_is "mixed shell+nvim set → union of both" $'zsh/ui.zsh\nnvim/init.lua' true true @@ -633,6 +634,18 @@ check "serve --help returns 0 (not mis-read as a bad port)" \ 'out=$(serve --help); (( $? == 0 )) && [[ $out == *"usage: serve"* ]]' check "extract -h returns 0 (not mis-read as a missing file)" \ 'out=$(extract -h); (( $? == 0 )) && [[ $out == *"usage: extract"* ]]' +# core-version (#4): reports the vendored Core stamp so an OS repo can tell WHICH Core +# it carries. $_CORE_VERSION_FILE resolves (via %x) to this repo's core.version here. +check "core-version prints the vendored SemVer stamp" \ + 'out=$(core-version); (( $? == 0 )) && [[ $out == "dotfiles-core "[0-9]* ]]' +check "core-version --help returns 0 (not mis-read)" \ + 'out=$(core-version --help); (( $? == 0 )) && [[ $out == *"usage: core-version"* ]]' +# core-doctor (#9): the shell-side health report. Must render and return 0 even on a +# bare box (every tool ✗) — it's read-only diagnostics, never a hard failure. +check "core-doctor renders a health report and returns 0" \ + 'out=$(NO_COLOR=1 core-doctor 2>&1); (( $? == 0 )) && [[ $out == *dotfiles-core* && $out == *"modern CLI"* ]]' +check "core-doctor --help returns 0 (not mis-read)" \ + 'out=$(core-doctor --help); (( $? == 0 )) && [[ $out == *"usage: core-doctor"* ]]' # core-help (U5): the width-aware renderer must emit every verb and never crash on its # kw arithmetic — including a pathologically narrow terminal where the key column clamps. check "core-help renders all verbs (wide terminal)" \ @@ -756,6 +769,11 @@ ln -s "$(command -v awk)" "$PMBIN/awk" ucheck "update: _pkgup_list surfaces upgradable package names (apt)" \ "source '$UPD'; out=\$(_pkgup_list); [[ \$out == *foo* && \$out == *bar* ]]" \ PATH="$PMBIN" UPDATE_CHECK_ENABLED=0 CORE_WELCOME=0 +# up --dry-run (#8): the non-destructive inspect — list what WOULD upgrade and exit 0, +# applying nothing. Same apt stub as above; assert the names print and the rc is 0. +ucheck "update: up --dry-run lists pending packages and exits 0 (applies nothing)" \ + "source '$UI'; source '$UPD'; out=\$(up --dry-run); (( \$? == 0 )) && [[ \$out == *foo* && \$out == *bar* ]]" \ + PATH="$PMBIN" UPDATE_CHECK_ENABLED=0 CORE_WELCOME=0 # core-help context-awareness (U7): a row whose tool is ABSENT on this box must be # tagged "needs ", while an always-on verb (mkcd) still renders normally. Drive # it on a bare PATH so fzf is guaranteed missing, making the assertion deterministic. @@ -763,6 +781,17 @@ _pm_only "" ucheck "core-help annotates an unavailable tool (needs fzf when fzf absent)" \ "source '$UI'; source '$FN'; out=\$(COLUMNS=120 NO_COLOR=1 core-help); [[ \$out == *'needs fzf'* && \$out == *mkcd* ]]" \ PATH="$PMBIN" UPDATE_CHECK_ENABLED=0 CORE_WELCOME=0 +# fzf.zsh verbs (fif/fbr) must degrade in Core's voice on a bare box — a raw "command +# not found" is the bug this guards (fcd already did; fif/fbr/zoxide-jump did not). +# Drive on an isolated PATH (fzf guaranteed absent) so the error path is deterministic. +FZF_FILE="$HERE/zsh/fzf.zsh" +_pm_only "" +ucheck "fif rejects cleanly without fzf (Core error voice, not 'command not found')" \ + "source '$UI'; source '$FZF_FILE' 2>/dev/null; out=\$(fif foo 2>&1); (( \$? != 0 )) && [[ \$out == *'fif: requires fzf'* ]]" \ + PATH="$PMBIN" UPDATE_CHECK_ENABLED=0 CORE_WELCOME=0 +ucheck "fbr rejects cleanly without fzf (Core error voice, not 'command not found')" \ + "source '$UI'; source '$FZF_FILE' 2>/dev/null; out=\$(fbr 2>&1); (( \$? != 0 )) && [[ \$out == *'fbr: requires fzf'* ]]" \ + PATH="$PMBIN" UPDATE_CHECK_ENABLED=0 CORE_WELCOME=0 # Colour degradation (U8): the nudge/welcome accents must drop from 24-bit hex to a # 256-colour code when the terminal doesn't advertise truecolor — so a 16/256-colour # TTY never receives a raw 24-bit escape. Assert both arms of the $COLORTERM gate. @@ -801,7 +830,7 @@ ucheck "update: welcome stays silent (no greet, no sentinel) without a tty" \ # command across all 9 repos, with nothing else to catch it. Put the dir on fpath (as # options.zsh does), run compinit, and assert each verb resolved to a completion. ucheck "completions: compinit wires every Core first-party completion" \ - "fpath=('$HERE/zsh/completions' \$fpath); autoload -Uz compinit && compinit -u -d '$SANDBOX/zcd-comp' >/dev/null 2>&1; for c in mkcd mkbak extract up serve cdup fcd please core-help cheat; do [[ -n \${_comps[\$c]:-} ]] || { print \"no completion registered for: \$c\"; exit 1; }; done" + "fpath=('$HERE/zsh/completions' \$fpath); autoload -Uz compinit && compinit -u -d '$SANDBOX/zcd-comp' >/dev/null 2>&1; for c in mkcd mkbak extract up serve cdup fcd fif fbr please core-help cheat core-version core-doctor; do [[ -n \${_comps[\$c]:-} ]] || { print \"no completion registered for: \$c\"; exit 1; }; done" # ── summary ─────────────────────────────────────────────────────────────────── summary diff --git a/zsh/completions/_core-doctor b/zsh/completions/_core-doctor new file mode 100644 index 0000000..75b07c8 --- /dev/null +++ b/zsh/completions/_core-doctor @@ -0,0 +1,4 @@ +#compdef core-doctor +# Completion for Core's `core-doctor` (zsh/functions.zsh). It prints a read-only health +# report of detected tools + integrations and takes no arguments. +_message 'core-doctor takes no arguments (prints the Core health report)' diff --git a/zsh/completions/_core-version b/zsh/completions/_core-version new file mode 100644 index 0000000..4a2ed1c --- /dev/null +++ b/zsh/completions/_core-version @@ -0,0 +1,4 @@ +#compdef core-version +# Completion for Core's `core-version` (zsh/functions.zsh). It prints the vendored +# Core layer's version stamp and takes no arguments. +_message 'core-version takes no arguments (prints the Core version stamp)' diff --git a/zsh/completions/_fbr b/zsh/completions/_fbr new file mode 100644 index 0000000..961c236 --- /dev/null +++ b/zsh/completions/_fbr @@ -0,0 +1,4 @@ +#compdef fbr +# Completion for Core's `fbr` (zsh/fzf.zsh). It takes NO arguments — the branch is +# chosen interactively through fzf — so there is nothing to complete. +_message 'fbr takes no arguments (it fuzzy-picks a git branch via fzf)' diff --git a/zsh/completions/_fif b/zsh/completions/_fif new file mode 100644 index 0000000..80221a1 --- /dev/null +++ b/zsh/completions/_fif @@ -0,0 +1,5 @@ +#compdef fif +# Completion for Core's `fif` (zsh/fzf.zsh): one argument, the text to search for +# inside files (rg + fzf). There is no canonical set to complete a free-text search +# term against, so describe the argument rather than offering bogus matches. +_arguments '1:search term (text to find inside files): ' diff --git a/zsh/completions/_up b/zsh/completions/_up index df2eff0..f70624e 100644 --- a/zsh/completions/_up +++ b/zsh/completions/_up @@ -1,4 +1,5 @@ #compdef up # Completion for Core's `up` package-updater (zsh/update.zsh). _arguments \ - '(-y --yes)'{-y,--yes}'[auto-confirm where safe (apt/dnf/zypper)]' + '(-y --yes -n --dry-run)'{-y,--yes}'[auto-confirm where safe (apt/dnf/zypper)]' \ + '(-y --yes -n --dry-run)'{-n,--dry-run}'[list upgradable packages and exit, touching nothing]' diff --git a/zsh/functions.zsh b/zsh/functions.zsh index 2aeb83f..7798abd 100644 --- a/zsh/functions.zsh +++ b/zsh/functions.zsh @@ -5,6 +5,70 @@ # Nothing OS-specific or offensive here — those live in the OS / Kali repos. # ────────────────────────────────────────────────────────────────────────────── +# Resolved path to the vendored version stamp. core.version sits one dir ABOVE zsh/, +# so from this module: %x = the file being sourced, :A resolves the bootstrap symlink +# back into core/zsh/functions.zsh, :h:h climbs to core/, then /core.version. Captured +# at source time (the proven pattern from options.zsh / maint.zsh). +typeset -g _CORE_VERSION_FILE="${${(%):-%x}:A:h:h}/core.version" + +# core-version — print the vendored Core layer's version. Lets you tell WHICH Core a +# given OS repo carries from inside it: the subtree squash records the commit, this +# records the human SemVer (core.version, bumped at release to match the git tag). +core-version() { + emulate -L zsh + _core_wants_help "$1" && { _core_help "core-version" "print the vendored Core layer's version"; return 0; } + if [[ -r "$_CORE_VERSION_FILE" ]]; then + print -r -- "dotfiles-core $(<"$_CORE_VERSION_FILE")" + else + _core_err "core-version: version stamp not found at $_CORE_VERSION_FILE" + return 1 + fi +} + +# core-doctor — the shell counterpart to nvim's `:checkhealth gerrrt`: a scannable +# report of which modern-CLI tools Core detected on THIS box and which integrations are +# live, so you can see at a glance what's degraded to a classic fallback. Probes live +# via _core_have (command -v), so it's honest even if tools.zsh hasn't run, and shows +# the RESOLVED binary names (fd vs fdfind, bat vs batcat) — the cross-distro detail that +# silently changes behaviour. Read-only: it inspects, never installs. +core-doctor() { + emulate -L zsh + _core_wants_help "$1" && { _core_help "core-doctor" "report Core's detected tools + active integrations on this box"; return 0; } + local g='' c='' d='' r='' + if [[ -t 1 && -z ${NO_COLOR:-} ]]; then + g=$'\e[32m' c=$'\e[36m' d=$'\e[2;37m' r=$'\e[0m' + fi + local ver="unknown" + [[ -r "$_CORE_VERSION_FILE" ]] && ver="$(<"$_CORE_VERSION_FILE")" + print -r -- "${c}dotfiles-core ${ver}${r} ${d}— core-doctor (✓ present · ✗ falls back to classic)${r}" + + # Grouped tool report: "group label" then a space-separated tool list. A tool is + # ✓ when it resolves on PATH, ✗ (dimmed) when Core degrades to the classic command. + local -a groups=( + "modern CLI" "eza bat fd rg fzf zoxide delta dust duf procs btop yazi" + "integrations" "starship atuin mise carapace gum sesh" + "data / net" "jq yq gron sd xh doggo glow op" + ) + local gi tool line + for ((gi = 1; gi <= ${#groups}; gi += 2)); do + print -r -- "${c}${groups[gi]}${r}" + line="" + for tool in ${=groups[gi + 1]}; do + if _core_have "$tool"; then line+=" ${g}✓${r} ${tool}" + else line+=" ${d}✗ ${tool}${r}"; fi + done + print -r -- " $line" + done + + # Resolved binary names + the detected package manager — the behaviour-affecting bits + # a bare ✓/✗ hides (Debian's fd→fdfind/bat→batcat; which `up` manager fires here). + print -r -- "${c}resolved${r}" + print -r -- " ${d}fd → ${FD_BIN:-(none)} bat → ${BAT_BIN:-(none)}${r}" + if (($+functions[_pkgup_mgr])); then + print -r -- " ${d}package manager → $(_pkgup_mgr)${r}" + fi +} + # mkcd — make a directory and cd into it mkcd() { _core_wants_help "$1" && { _core_help "mkcd " "make a directory (and parents) and cd into it"; return 0; } @@ -273,7 +337,9 @@ core-help() { "maint-run|run daily maintenance now" "maint-log [-f]|view (or follow) the maintenance log" ) - print -r -- "${title}dotfiles Core — cheat sheet${te} ${dc}(run \`core-help\` anytime)${de}" + local ver="" + [[ -r "$_CORE_VERSION_FILE" ]] && ver=" v$(<"$_CORE_VERSION_FILE")" + print -r -- "${title}dotfiles Core${ver} — cheat sheet${te} ${dc}(run \`core-help\` anytime)${de}" # Key column is derived from the WIDEST key, not a fixed 22 — so alignment stays # correct if a longer verb is ever added (the old hard-coded width silently broke # alignment past 22 chars) and isn't padded wider than the content needs. On a narrow @@ -306,6 +372,6 @@ core-help() { fi fi done - print -r -- "${dc} 1Password: opsecret · openv · optoken · opssh full reference: README.md${de}" + print -r -- "${dc} 1Password: opsecret · openv · optoken · opssh health: core-doctor · version: core-version${de}" } alias cheat='core-help' diff --git a/zsh/fzf.zsh b/zsh/fzf.zsh index 37fb046..79f9b46 100644 --- a/zsh/fzf.zsh +++ b/zsh/fzf.zsh @@ -24,9 +24,25 @@ export FZF_CTRL_R_OPTS=' --sort ' -export _FZF_PREVIEW_CMD='bat --color=always --style=plain,numbers --line-range=:500 {}' +# Previews run in a subshell with the literal command string baked in, so the binary +# name must be RESOLVED here — not assumed. tools.zsh (loaded before this file) sets +# $BAT_BIN to the real name (Debian/Ubuntu ship bat as `batcat`); using a literal +# `bat` printed "command not found" in every preview pane on those distros. Fall back +# to cat/ls on a bare box so the pane shows the file/dir instead of an error. +if [[ -n ${BAT_BIN:-} ]]; then + export _FZF_PREVIEW_CMD="$BAT_BIN --color=always --style=plain,numbers --line-range=:500 {}" +else + export _FZF_PREVIEW_CMD='cat {}' +fi +# Dir preview: eza when present, classic `ls` otherwise (eza has no rename quirk — the +# only failure mode is absence, so a fallback is all it needs). +if [[ -n ${HAVE_EZA:-} ]]; then + export _FZF_DIR_PREVIEW='eza --icons=always --tree --level=1 {}' +else + export _FZF_DIR_PREVIEW='ls -la {}' +fi export FZF_CTRL_T_OPTS="--preview '$_FZF_PREVIEW_CMD'" -export FZF_ALT_C_OPTS="--preview 'eza --icons=always --tree --level=1 {}'" +export FZF_ALT_C_OPTS="--preview '$_FZF_DIR_PREVIEW'" # ========================================================= # Widget: Ctrl+F — file picker (no hidden files) @@ -45,10 +61,17 @@ zle -N _fzf_file_no_hidden # ========================================================= _fzf_zoxide_jump() { local result + # Bound unconditionally in bindings.zsh, so guard here: on a box without zoxide/fzf, + # warn in Core's voice and repaint the prompt rather than spewing "command not found". + if ! _core_have zoxide || ! _core_have fzf; then + _core_warn "Alt-Z: needs zoxide + fzf" + zle reset-prompt + return 1 + fi result=$(zoxide query -l | fzf \ --no-sort \ --prompt="Jump to Folder ❯ " \ - --preview="eza --icons=always --tree --level=1 {}") + --preview="$_FZF_DIR_PREVIEW") if [[ -n "$result" ]]; then cd "$result" || return fi @@ -105,12 +128,22 @@ zle -N _tmux_sessionizer fif() { _core_wants_help "$1" && { _core_help "fif " "find text inside files (rg + fzf + preview)"; return 0; } [[ -z "$1" ]] && { _core_usage "fif "; return 1; } + # Defensive: degrade in Core's voice on a bare box instead of a raw "command not + # found" — matches fcd's guard (functions.zsh). + _core_have fzf || { _core_err "fif: requires fzf"; _core_hint "install fzf, then retry"; return 1; } + _core_have rg || { _core_err "fif: requires ripgrep (rg)"; _core_hint "install ripgrep, then retry"; return 1; } + # Preview the first match with $BAT_BIN's line highlight when bat is present; fall + # back to a plain `cat` pane on a bare box (the highlight-line flag is bat-specific). + local fif_preview + if [[ -n ${BAT_BIN:-} ]]; then + fif_preview="$BAT_BIN --style=numbers --color=always --highlight-line \$(rg --line-number --no-messages \"\$FIF_TERM\" {} | cut -d: -f1 | head -n 1) {}" + else + fif_preview='cat {}' + fi FIF_TERM="$1" rg --files-with-matches --no-messages "$1" | fzf \ --height 80% --layout=reverse --border=rounded \ --prompt="Text Match ❯ " \ - --preview 'bat --style=numbers --color=always \ - --highlight-line $(rg --line-number --no-messages "$FIF_TERM" {} \ - | cut -d: -f1 | head -n 1) {}' \ + --preview "$fif_preview" \ --preview-window="right:65%:wrap:border-left" } @@ -119,6 +152,12 @@ fif() { # ========================================================= fbr() { _core_wants_help "$1" && { _core_help "fbr" "fuzzy git-branch checkout (local + remote)"; return 0; } + _core_have fzf || { _core_err "fbr: requires fzf"; _core_hint "install fzf, then retry"; return 1; } + _core_have git || { _core_err "fbr: requires git"; return 1; } + git rev-parse --is-inside-work-tree >/dev/null 2>&1 || { + _core_err "fbr: not inside a git repository" + return 1 + } local branch branch=$(git branch --all 2>/dev/null | grep -v HEAD | fzf --preview 'git log --oneline --color=always {1} | head -20' | diff --git a/zsh/plugins.zsh b/zsh/plugins.zsh index 409090a..edceb84 100644 --- a/zsh/plugins.zsh +++ b/zsh/plugins.zsh @@ -170,7 +170,15 @@ fi _zplugin_load Aloxaf fzf-tab if (($+functions[fzf-tab-complete])); then zstyle ':fzf-tab:*' fzf-command fzf - zstyle ':fzf-tab:complete:cd:*' fzf-preview 'eza --icons --tree --level=1 $realpath' + # Resolve the preview binaries the same way fzf.zsh does — fzf-tab runs these in a + # subshell, so a literal `eza`/`bat` would break on a bare box (no eza) or Debian + # (bat is `batcat`). The cd preview degrades to `ls`; the file preview reuses the + # already-resolved $_FZF_PREVIEW_CMD (bat→cat, set in fzf.zsh, loaded before this). + if [[ -n ${HAVE_EZA:-} ]]; then + zstyle ':fzf-tab:complete:cd:*' fzf-preview 'eza --icons --tree --level=1 $realpath' + else + zstyle ':fzf-tab:complete:cd:*' fzf-preview 'ls -la $realpath' + fi zstyle ':fzf-tab:complete:*:*' fzf-preview '$_FZF_PREVIEW_CMD $realpath 2>/dev/null' zstyle ':fzf-tab:*' switch-group '<' '>' fi diff --git a/zsh/update.zsh b/zsh/update.zsh index 28341a2..f9a3182 100644 --- a/zsh/update.zsh +++ b/zsh/update.zsh @@ -215,9 +215,10 @@ up() { emulate -L zsh # Help BEFORE anything else: without this, `up --help` fell through (not `-y`, so # yes=0) and proceeded to actually apply updates — a help flag must never do that. - _core_wants_help "$1" && { _core_help "up [-y|--yes]" "apply package updates (interactive; -y auto-confirms where safe)"; return 0; } - local yes=0 + _core_wants_help "$1" && { _core_help "up [-y|--yes] [-n|--dry-run]" "apply package updates (interactive; -y auto-confirms where safe; -n only lists)"; return 0; } + local yes=0 dry=0 [[ "$1" == (-y|--yes) ]] && yes=1 + [[ "$1" == (-n|--dry-run) ]] && dry=1 local y=() ((yes)) && y=(-y) local mgr @@ -226,6 +227,20 @@ up() { _core_err "up: no supported package manager found" return 1 fi + # Dry run: show what WOULD upgrade and exit 0, touching nothing — the non-destructive + # inspect that the count-only nudge and the (interactive-only) pre-confirm preview + # didn't offer. Uses the same non-root, no-mutation _pkgup_list as the preview below. + if ((dry)); then + local -a pending + pending=(${(f)"$(_pkgup_list 2>/dev/null)"}) + if ((${#pending})); then + _core_ok "up: ${#pending} package$([[ ${#pending} -ne 1 ]] && echo s) upgradable via ${mgr}:" + print -rl -- "${(@)pending/#/ }" + else + _core_ok "up: nothing to upgrade (via ${mgr})" + fi + return 0 + fi # Defensive pre-confirm (skipped by -y): name the manager BEFORE touching the # system, so `up` on the wrong box is a one-keystroke abort, not a surprise sync. # _core_confirm declines with no TTY, so `up` (sans -y) stays interactive-only.