feat: public dotfiles tier — no credential, no identity, one installer

Fresh history. This is the repo a throwaway VM clones anonymously: it brings a
machine to a working baseline and carries nothing that makes it mine.

56 files. 50 land in $HOME, 3 are chezmoi metadata, 2 are repo documentation,
1 is the manifest, and a 15-file test harness stays behind in .tests/.

What did not travel, and why:

  encrypted_private_bws-token.age   a real credential; age is dropped entirely
  .chezmoidata/bws.toml             env-var -> secret-id map; belongs with the
                                    tier that can use it
  SECRETS.md                        documentation of the rules, not config
  finish-setup.sh.tmpl              superseded by dotup
  nvim/init.lua.backup              dead file
  dot_claude/**, dot_codex/**,      120 files of agent config, private tier
  dot_pi/**

De-identified rather than dropped:

  .gitconfig   [user], the GitHub ssh rewrite and both Gitea host rewrites are
               identity, not configuration. They move behind an [include] of
               ~/.config/git/config.local, which the private tier writes. Git
               treats a missing include as a no-op, so a public-only machine
               reads the file and stops.
  .zshrc       the two gitea aliases carried a personal domain and a LAN IP.
               They move behind a guarded source of ~/.config/zsh/local.zsh,
               the sibling of the secrets.zsh seam phase 2 established.
  nvim         a commented-out LM Studio endpoint naming a LAN address.
  ghostty      a stale auto-generated header naming an absolute home directory.

Newly captured, never tracked before: ~/.zshenv, ~/.config/gh/config.yml. The
former sourced ~/.cargo/env unguarded, so every zsh on a machine without rustup
printed an error -- the same shape as the unguarded oh-my-zsh source phase 2
fixed. It is guarded now.

.chezmoiexternal.toml grows from one entry to six. oh-my-zsh, powerlevel10k,
zsh-autosuggestions, zsh-ai and tpm were hand-installed and declared nowhere,
which is why `chezmoi init --apply` on a clean box produced a .zshrc that broke
the shell it configures. The theme and both plugins nest under
.oh-my-zsh/custom/, which is what $ZSH_CUSTOM resolves to.

dotup gains an install engine. It resolves each selected package to a channel
(apt, brew, npm, uv, snap, deb, flatpak, tarball, script, builtin) through one
function every consumer reads, probes apt-cache before batching so a name apt
does not know moves to brew instead of failing all thirty, and retries
individually if a batch still fails -- which earned its keep on the first real
container run, where mermaid-cli's puppeteer dependency failed and the other
twelve npm packages installed anyway. --unattended computes safe defaults fresh
from the manifest rather than inheriting a state file, and refuses private and
invasive rows outright even when a stale state file ticks them.

The manifest gains @spec, a second directive kind alongside @needs, carrying the
argument a channel needs but a package name cannot supply -- the scoped npm
name, the flatpak app id, the .deb source. The TSV stays five columns wide.

Three bugs the container runs found, all fixed here:

  * `apt install nodejs` gives you node WITHOUT npm on Ubuntu, so all thirteen
    npm packages failed on a fresh box. The manifest asks apt for both names.
  * A tool installed a moment ago is not on this process's PATH -- uv lands in
    ~/.local/bin, npm -g honours the ~/.npmrc prefix, linuxbrew is outside a
    non-login PATH. Resolved by looking in the places we just wrote to, never by
    exporting a modified PATH.
  * `A || { B && C; }` is one || list, so when `command -v sudo` failed the list
    failed and `set -e` killed dotup at load. On a non-root machine with no
    sudo it died before printing anything. There is a regression test.

.zshenv and .p10k.zsh are marked private_. Both are shell code the login shell
executes and both applied at 664, group-writable. Third occurrence of the class
of bug phase 1 found on .pi/agent/auth.json and phase 2 found on .zshrc; the
first one found on purpose rather than by accident.

Verification: 81 assertions, 81/81 on this box and in ubuntu:24.04, ubuntu:22.04
and debian:12. The installer is driven against a directory of fake package
managers that record what they were asked to do and install nothing, so the
engine is exercised end to end without a package landing on the test machine.
`gitleaks detect` over the full history and the working tree: no leaks found,
with no allowlist and no .gitleaks.toml.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
bcherb2
2026-08-17 00:11:52 -04:00
commit b487b0e855
71 changed files with 7824 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# chezmoi configuration for the PUBLIC tier.
# Rendered into the config file named by `chezmoi init` (see README.md).
#
# There is deliberately no `encryption` key and no [age] section. The previous
# repo carried both, pointing at ~/.config/chezmoi/key.txt and an age recipient.
# That layer is gone: the one secret in the system is fetched over an
# authenticated HTTPS connection at install time, so there is nothing left on
# disk for a key to unlock. On one of the three machines the identity file was
# already 0 bytes, mode 664 — broken, silently, with nothing surfacing it.
#
# There are deliberately no [data] prompts either. This tier must clone and
# apply on a throwaway VM with nobody at the keyboard, so it may not ask a
# question it cannot answer. Your name, email, signing key and Gitea hosts are
# identity, not configuration; they are prompted once by the PRIVATE tier and
# land in ~/.config/git/config.local and ~/.config/zsh/local.zsh, which this
# tier includes if present and ignores if not.
#
# If this file ever needs a real key, that is a signal worth pausing on: the
# public tier's whole value is that it is the same on every machine.
+76
View File
@@ -0,0 +1,76 @@
# Externals — the shell dependency chain, declared for the first time.
#
# None of these were tracked or declared anywhere before. `.zshrc` sourced
# $ZSH/oh-my-zsh.sh unguarded, so `chezmoi init --apply` on a clean box produced
# a .zshrc that broke the shell it configures, and nothing in the repo said why.
# The guard went into .zshrc in phase 2; the six entries below are the other
# half — the thing the guard is waiting for.
#
# ORDER MATTERS. chezmoi processes externals in target-path order, so
# `.oh-my-zsh` is cloned before anything under `.oh-my-zsh/custom/`. The theme
# and the two plugins are nested there deliberately: $ZSH_CUSTOM is
# ~/.oh-my-zsh/custom (see .zshrc), and oh-my-zsh's own custom/.gitignore
# excludes everything but example.zsh, so the nested clones do not dirty the
# parent working tree on refresh.
#
# --depth=1 on clone is what each of these projects recommends; a full
# oh-my-zsh history is ~50 MB of bootstrap nobody reads. `pull --ff-only` keeps
# a refresh from ever creating a merge commit in someone else's repo.
[".tmux"]
type = "git-repo"
url = "https://github.com/gpakosz/.tmux.git"
refreshPeriod = "168h"
[".tmux".pull]
args = ["--ff-only"]
[".oh-my-zsh"]
type = "git-repo"
url = "https://github.com/ohmyzsh/ohmyzsh.git"
refreshPeriod = "168h"
[".oh-my-zsh".clone]
args = ["--depth=1"]
[".oh-my-zsh".pull]
args = ["--ff-only"]
[".oh-my-zsh/custom/themes/powerlevel10k"]
type = "git-repo"
url = "https://github.com/romkatv/powerlevel10k.git"
refreshPeriod = "168h"
[".oh-my-zsh/custom/themes/powerlevel10k".clone]
args = ["--depth=1"]
[".oh-my-zsh/custom/themes/powerlevel10k".pull]
args = ["--ff-only"]
[".oh-my-zsh/custom/plugins/zsh-autosuggestions"]
type = "git-repo"
url = "https://github.com/zsh-users/zsh-autosuggestions.git"
refreshPeriod = "168h"
[".oh-my-zsh/custom/plugins/zsh-autosuggestions".clone]
args = ["--depth=1"]
[".oh-my-zsh/custom/plugins/zsh-autosuggestions".pull]
args = ["--ff-only"]
# .zshrc adds this to the plugins array only when this directory exists, and
# only here — it used to be in the array on every platform AND separately
# sourced from /opt/homebrew on macOS, so it loaded twice on mac and not at all
# on Linux. Default branch is `main`, not `master`.
[".oh-my-zsh/custom/plugins/zsh-ai"]
type = "git-repo"
url = "https://github.com/matheusml/zsh-ai.git"
refreshPeriod = "168h"
[".oh-my-zsh/custom/plugins/zsh-ai".clone]
args = ["--depth=1"]
[".oh-my-zsh/custom/plugins/zsh-ai".pull]
args = ["--ff-only"]
# Was a checked-in TPM checkout under .config/tmux/plugins. It is a clone of
# someone else's repo; it belongs here, not in git history.
[".config/tmux/plugins/tpm"]
type = "git-repo"
url = "https://github.com/tmux-plugins/tpm.git"
refreshPeriod = "168h"
[".config/tmux/plugins/tpm".clone]
args = ["--depth=1"]
[".config/tmux/plugins/tpm".pull]
args = ["--ff-only"]
+42
View File
@@ -0,0 +1,42 @@
# .chezmoiignore — PUBLIC tier.
#
# This is gitignore syntax. A `#` in the middle of a line becomes part of the
# pattern, silently producing an entry that matches nothing, so every comment
# in this file is on its own line. That mistake was caught by inspection once
# already; it does not announce itself.
#
# Ignoring changes what chezmoi manages. It never removes or modifies a file on
# any machine.
# Repo documentation, not a dotfile. Without this it lands as ~/README.md.
README.md
# Auto-generated by lazy.nvim on every plugin update.
.config/nvim/lazy-lock.json
# chezmoi's own config directory. Never manage the thing that configures the
# manager.
.config/chezmoi
.config/chezmoi/**
# Nested git checkouts. Configs that are repos get their own repo.
**/.git
**/.git/**
# App-managed state. Each of these is rewritten by the application that owns
# it, so a tracked copy can never match the live one and `chezmoi status` is
# never empty. Carried in the source as a starting point, applied never.
.config/btop/btop.conf
.config/herdr/config.toml
# Belt and braces on the tier boundary. The public repo does not manage any of
# these and must never start: a stray `chezmoi add ~/.config/gh` would
# otherwise sweep up an OAuth token, and the same for the bws token and the
# generated key file. These are the exact paths the phase 3 negative test
# asserts are absent on a public-only machine.
.config/gh/hosts.yml
.config/zsh/secrets.zsh
.config/bitwarden
.config/bitwarden/**
.claude.json
.claude/settings.local.json
+34
View File
@@ -0,0 +1,34 @@
# This repo is public and holds no credential. These patterns are not the
# control that keeps it that way -- `gitleaks detect` over the full history is,
# and it runs with zero allowlist entries. These are here so an accident has to
# be deliberate.
# Anything shaped like a credential
*.age
*.key
*.pem
*_key
*.secret
*_secret
.env
.env.*
!.env.example
# Files the tier boundary says belong elsewhere
private_dot_config/bitwarden/
private_dot_config/gh/hosts.yml
private_dot_config/zsh/secrets.zsh
.chezmoidata/bws.toml
# chezmoi runtime state, never the source
.chezmoistate.boltdb
.chezmoi.toml
!.chezmoi.toml.tmpl
# Picker state and its cached fzf, both machine-local by design
.tests/state/
.tests/fzfver
# OS junk
.DS_Store
**/dot_DS_Store
+32
View File
@@ -0,0 +1,32 @@
#!/bin/sh
# Every fake in this directory is a symlink to this file. It records the call
# and succeeds, so the installer can be driven end to end without a single
# package being installed, on this machine or in a container.
#
# `sudo` is faked too, and deliberately does NOT exec its argument: a run as
# root in a container would otherwise reach the real `rm -rf /opt/nvim`.
printf '%s %s\n' "${0##*/}" "$*" >> "${DOTUP_TEST_LOG:?DOTUP_TEST_LOG unset}"
case ${0##*/} in
apt-cache)
# Stand in for a real apt cache: everything is known except `gh`, which is
# genuinely absent from stock Ubuntu and is the manifest's real example of a
# name that has to fall through to brew.
case $2 in gh) exit 100 ;; esac
printf 'Package: %s\n' "$2"; exit 0 ;;
curl)
# Enough shape for the two bespoke handlers that parse a response.
for a in "$@"; do
case $a in
*go.dev/VERSION*) echo go1.99.0; exit 0 ;;
*api.github.com*) echo ' "browser_download_url": "https://example.invalid/fake_amd64.deb"'; exit 0 ;;
esac
done
prev=
for a in "$@"; do
[ "$prev" = "-o" ] && { : > "$a"; exit 0; }
prev=$a
done
exit 0 ;;
esac
exit 0
+1
View File
@@ -0,0 +1 @@
_log
+1
View File
@@ -0,0 +1 @@
_log
+1
View File
@@ -0,0 +1 @@
_log
+1
View File
@@ -0,0 +1 @@
_log
+1
View File
@@ -0,0 +1 @@
_log
+1
View File
@@ -0,0 +1 @@
_log
+1
View File
@@ -0,0 +1 @@
_log
+1
View File
@@ -0,0 +1 @@
_log
+1
View File
@@ -0,0 +1 @@
_log
+1
View File
@@ -0,0 +1 @@
_log
+1
View File
@@ -0,0 +1 @@
_log
+4
View File
@@ -0,0 +1,4 @@
#!/bin/sh
# Stands in for a Mac. The macOS half of the manifest has no CI anywhere, and
# resolution is pure data, so it can be checked from here.
case ${1:-} in -m) echo arm64 ;; *) echo Darwin ;; esac
+46
View File
@@ -0,0 +1,46 @@
#!/bin/sh
# Drive the real picker over fzf's --listen API and assert the cursor does not
# jump on reload. Without that property, ticking four packages in a row means
# four scroll-backs and the tree design is unusable.
#
# fzf needs a terminal, so the work happens inside `script`, which re-enters
# this file with --inner rather than serialising a shell function (declare -f
# is bash-only, and the suite runs under dash).
set -eu
cd "$(dirname "$0")"
ROOT=$(CDPATH= cd -- .. && pwd)
export DOTUP_STATE=$PWD/state
export DOTUP_MANIFEST=$ROOT/dot_local/share/dotup/packages.tsv
S=$ROOT/dot_local/bin/executable_dotup
PORT=${DOTUP_TEST_PORT:-$((20000 + $$ % 20000))}
if [ "${1:-}" != "--inner" ]; then
DOTUP_TEST_PORT=$PORT; export DOTUP_TEST_PORT
script -qec "sh $0 --inner" /dev/null 2>&1 \
| tr -d '\r' | grep -oE 'CURSOR_(HELD|MOVED)[^ ]*|ROW_FLIPPED' || true
exit 0
fi
export FZF_DEFAULT_OPTS="--listen $PORT"
(
sleep 1.3
curl -s -XPOST "localhost:$PORT" -d 'pos(4)' >/dev/null; sleep 0.3
before=$(curl -s "localhost:$PORT" | grep -o '"position":[0-9]*')
row1=$(curl -s "localhost:$PORT" | grep -o '"current":{[^}]*}')
curl -s -XPOST "localhost:$PORT" \
-d "execute-silent($S toggle {2})+reload($S render)" >/dev/null
sleep 0.8
after=$(curl -s "localhost:$PORT" | grep -o '"position":[0-9]*')
row2=$(curl -s "localhost:$PORT" | grep -o '"current":{[^}]*}')
if [ "$before" = "$after" ]; then echo "CURSOR_HELD"; else echo "CURSOR_MOVED"; fi
# same row, opposite tick state
case "$row1|$row2" in
*'[x]'*'|'*'[ ]'*|*'[ ]'*'|'*'[x]'*) echo "ROW_FLIPPED" ;;
esac
curl -s -XPOST "localhost:$PORT" -d 'abort' >/dev/null
) &
sh "$S" preset defaults
sh "$S" expand-all >/dev/null
sh "$S" pick >/dev/null 2>&1 || true
wait
Executable
+347
View File
@@ -0,0 +1,347 @@
#!/bin/sh
# dotup test suite.
#
# Everything here is pty-free and runs unchanged in a container: the toggle
# rule is pure state-file logic, the match-confinement test uses
# `fzf --filter`, which is non-interactive, and the installer is driven against
# a directory of fake package managers that record what they were asked to do
# and install nothing. The one test that genuinely needs a terminal is fenced
# off at the bottom and skips cleanly without one.
#
# sh .tests/test.sh on the host
# sh .tests/test.sh --docker in a clean ubuntu:24.04 container
#
# NOTHING IN THIS FILE INSTALLS A PACKAGE. If you add a case that could, it
# belongs in a container and behind an explicit opt-in, not here.
set -eu
cd "$(dirname "$0")"
if [ "${1:-}" = "--docker" ]; then
# curl and script are here so the pty test runs too: the container has
# apt's fzf 0.44.1, not whatever the dev box has, and the cursor-on-reload
# behaviour is the one property that must hold on the older release.
exec docker run --rm -v "$(cd .. && pwd):/w:ro" "${DOTUP_TEST_IMAGE:-ubuntu:24.04}" sh -c '
apt-get update -qq
apt-get install -y -qq fzf curl ca-certificates >/dev/null 2>&1
cp -r /w /work && cd /work/.tests && rm -rf state fzfver
echo " distro fzf: $(fzf --version 2>/dev/null || echo none)"
sh ../dot_local/bin/executable_dotup preflight # exactly what a real run does first
exec sh test.sh'
fi
ROOT=$(CDPATH= cd -- .. && pwd)
D=$ROOT/dot_local/bin/executable_dotup
M=$ROOT/dot_local/share/dotup/packages.tsv
export DOTUP_STATE=$PWD/state
export DOTUP_MANIFEST=$M
pass=0; fail=0
ok() { pass=$((pass+1)); printf ' \033[32mok\033[0m %s\n' "$1"; }
no() { fail=$((fail+1)); printf ' \033[31mFAIL\033[0m %s\n' "$1"; }
is() { # is <desc> <expected> <actual>
if [ "$2" = "$3" ]; then ok "$1"; else no "$1 — want [$2] got [$3]"; fi
}
has() { # has <desc> <needle> <haystack>
case $3 in *"$2"*) ok "$1" ;; *) no "$1 — [$2] not in output" ;; esac
}
hasnt(){ # hasnt <desc> <needle> <haystack>
case $3 in *"$2"*) no "$1 — [$2] IS in output" ;; *) ok "$1" ;; esac
}
# Normalise before parsing: "[x]" is one awk field but "[ ]" is two, so column
# positions shift with tick state. Collapse the box to a single token first.
rows() { sh $D render | sed 's/\x1b\[[0-9;]*m//g; s/\[[x~ ]\]/BOX/'; }
grp() { rows | awk -v g="$1" '$1!="BOX" && $3==g {print $4}'; }
# Every selected key matching a flag. Trailing ":" keeps a failed grep on the
# last item from tripping set -e via the loop's exit status.
picked(){ awk -F'\t' -v f="$1" '!/^#/ && !/^@/ && NF>=3 && $3==f {print $1"/"$2}' "$M" \
| while read -r k; do grep -qxF "$k" state/selected && echo "$k"; done; :; }
missed(){ awk -F'\t' -v f="$1" '!/^#/ && !/^@/ && NF>=3 && $3==f {print $1"/"$2}' "$M" \
| while read -r k; do grep -qxF "$k" state/selected || echo "$k"; done; :; }
reset() { rm -rf state; sh $D preset defaults; }
printf '\n\033[1mtoggle rule\033[0m\n'
reset
is "package off -> group goes partial" "2/3" \
"$(sh $D toggle p:media/sox >/dev/null; grp media)"
is "group partial -> all on" "3/3" \
"$(sh $D toggle g:media >/dev/null; grp media)"
is "group full -> all off" "0/3" \
"$(sh $D toggle g:media >/dev/null; grp media)"
is "group empty -> all on" "3/3" \
"$(sh $D toggle g:media >/dev/null; grp media)"
is "bulk, mixed set -> all on" "3/3" \
"$(sh $D toggle p:gpu/cuda-toolkit >/dev/null
sh $D toggle p:gpu/nvidia-driver p:gpu/cuda-toolkit p:gpu/container-toolkit >/dev/null
grp gpu)"
is "bulk, all on -> all off" "0/3" \
"$(sh $D toggle p:gpu/nvidia-driver p:gpu/cuda-toolkit p:gpu/container-toolkit >/dev/null
grp gpu)"
printf '\n\033[1mdependencies — @needs auto-ticks, both directions\033[0m\n'
on() { grep -qxF "$1" state/selected && echo on || echo off; }
reset; sh $D toggle p:networking/xrdp >/dev/null
is "ticking xrdp pulls in the desktop group" "2/2" "$(grp desktop)"
sh $D toggle g:desktop >/dev/null
is "unticking desktop drops xrdp" "off" "$(on networking/xrdp)"
is "unticking desktop drops xorgxrdp" "off" "$(on networking/xorgxrdp)"
reset; sh $D toggle p:gpu/container-toolkit >/dev/null
is "container-toolkit pulls in docker" "3/3" "$(grp docker)"
reset; sh $D toggle p:core/node >/dev/null
is "dropping node drops codex" "off" "$(on agents/codex)"
is "dropping node drops mermaid-cli" "off" "$(on core/mermaid-cli)"
is "…and transitively drops neovim" "off" "$(on core/neovim)"
is "but leaves unrelated packages be" "on" "$(on core/ripgrep)"
# The directives must not leak into package parsing.
is "@needs lines are not packages" "" \
"$(rows | awk '$1!="BOX" && $3 ~ /@/ {print $3}')"
is "group count unchanged by directives" "11" \
"$(awk -F'\t' '!/^[#@]/ && NF>=3 {print $1}' "$M" | uniq | grep -c .)"
# @spec is the second directive kind and must be as invisible as the first.
is "@spec lines are not packages" "0" \
"$(sh $D render | cut -f2 | grep -c 'spec' || true)"
reset; sh $D toggle p:core/uv >/dev/null
is "dropping uv drops specify-cli" "off" "$(on agents/specify-cli)"
printf '\n\033[1mrisk model — the invariants that matter\033[0m\n'
reset
is "defaults tick nothing invasive" "" "$(picked invasive)"
is "defaults tick nothing private" "" "$(picked private)"
is "defaults tick every safe package" "" "$(missed safe)"
# A group must never render a flag milder than something inside it.
bad=$(rows | awk '$1!="BOX" && $2=="BOX" {print $3, $5}' \
| while read -r g f; do
worst=$(awk -F'\t' -v g="$g" '!/^#/ && !/^@/ && NF>=3 && $1==g {
s=($3=="invasive")?3:($3=="private")?2:($3=="gui")?1:0
if (s>m) m=s } END{ print (m==3)?"invasive":(m==2)?"private":(m==1)?"gui":"safe" }' "$M")
[ "$f" = "$worst" ] || echo "$g shows $f, worst child is $worst"
done; :)
is "no group looks safer than its contents" "" "$bad"
printf '\n\033[1mmatch confinement — regression for the ^t bug\033[0m\n'
if command -v fzf >/dev/null; then
sh $D expand-all >/dev/null # every package row visible
grep -q gpu/nvidia-driver <<-EOF || sh $D expand-all >/dev/null
$(sh $D render | cut -f2)
EOF
rows=$(sh $D render | sed 's/\x1b\[[0-9;]*m//g')
hit=$(printf '%s\n' "$rows" | fzf --exact --filter=nvidia --no-sort | cut -f2 | sort)
is "--exact: 'nvidia' hits only gpu packages" \
"p:gpu/container-toolkit
p:gpu/cuda-toolkit
p:gpu/nvidia-driver" "$hit"
# The bug: without --exact this also matches docker, networking and desktop,
# and ^t silently switched all three on.
loose=$(printf '%s\n' "$rows" | fzf --filter=nvidia --no-sort | cut -f2 \
| grep -cv '^p:gpu/' || true)
if [ "$loose" -gt 0 ]; then
ok "fuzzy would over-match $loose non-gpu rows (why --exact is required)"
else
no "fuzzy no longer over-matches — re-check whether --exact is still load-bearing"
fi
is "the fzf invocation carries --exact" "1" \
"$(grep -c '\--ansi --exact' $D)"
# Every action the picker binds must exist on THIS fzf. Ubuntu 24.04 ships
# 0.44.1; a dev box may be 28 releases ahead. fzf validates binds at
# startup, and empty stdin makes it exit without needing a terminal.
badbind=$(sed -n 's/.*--bind "\([^"]*\)".*/\1/p; s/.*--bind '"'"'\([^'"'"']*\)'"'"'.*/\1/p' $D \
| sed "s|\$SELF|$D|g" \
| while read -r b; do
out=$(fzf --bind "$b" </dev/null 2>&1 || true)
case $out in *nvalid*|*nknown*|*nsupported*) echo "$b" ;; esac
done; :)
is "every binding parses on fzf $(fzf --version | cut -d' ' -f1)" "" "$badbind"
else
printf ' \033[33mskip\033[0m fzf not installed\n'
fi
printf '\n\033[1mpreflight — the picker brings its own fzf\033[0m\n'
vc() { sh $D vercmp "$1" "$2" && echo ge || echo lt; }
is "0.44.1 clears the floor" "ge" "$(vc 0.44.1 0.44.0)"
is "0.29 (jammy) does not" "lt" "$(vc 0.29 0.44.0)"
is "0.38 (bookworm) does not" "lt" "$(vc 0.38.0 0.44.0)"
is "compare is numeric, not lexical" "ge" "$(vc 0.100.0 0.44.0)"
is "0.9.5 is not above 0.44.0" "lt" "$(vc 0.9.5 0.44.0)"
is "preflight resolves a fzf above the floor" "ge" \
"$(vc "$(sh $D fzf-path | xargs -r -I{} sh -c '{} --version' | awk '{print $1}')" 0.44.0)"
# The picker must call its resolved binary, never whatever PATH happens to hold.
is "picker invokes the resolved binary" "1" \
"$(grep -c 'cmd_render | "\$FZF"' $D)"
is "picker never calls bare fzf" "0" \
"$(grep -c 'render | fzf ' $D)"
# The whole point of the cache: PATH and ~/.local/bin are left alone.
is "preflight writes nothing to ~/.local/bin" "absent" \
"$(sh $D preflight >/dev/null 2>&1; [ -e "$HOME/.local/bin/fzf" ] && echo present || echo absent)"
is "preflight does not export PATH" "0" \
"$(grep -c 'PATH=.*export PATH' $D)"
# fzf is still offered as a normal package — for your shell, not for the picker.
is "manifest still offers fzf to you" "core/fzf" \
"$(awk -F'\t' '!/^#/ && !/^@/ && NF>=3 && $2=="fzf" {print $1"/"$2}' "$M")"
printf '\n\033[1mplatform resolution\033[0m\n'
is "brew-only pkg resolves on linux (omp)" "" \
"$(sh $D expand g:agents >/dev/null
sh $D render | sed 's/\x1b\[[0-9;]*m//g' | grep 'omp' | grep -o 'unavailable' || true)"
# One place decides what installs a package; everything downstream reads it.
rs() { sh $D resolve "$1" | tr '\t' ' '; }
is "plain apt name resolves to apt" "apt sox" "$(rs media/sox)"
is "no apt package falls through to brew" "brew lazygit" "$(rs core/lazygit)"
is "tap-only pkg falls through to brew" "brew can1357/tap/omp" "$(rs agents/omp)"
is "special channel carries its @spec" "npm @openai/codex" "$(rs agents/codex)"
is "…and the scoped mermaid name" "npm @mermaid-js/mermaid-cli" "$(rs core/mermaid-cli)"
is "deb channel carries a source" "deb gh:mkasberg/ghostty-ubuntu:_amd64.deb" "$(rs apps/ghostty)"
is "snap channel renames to the binary" "snap bw" "$(rs core/bitwarden-cli)"
is "tarball is dispatched, not named" "tarball neovim" "$(rs core/neovim)"
is "an unknown key says so" "missing" "$(rs nope/nope | tr -d ' ')"
# The macOS half of the manifest is exercised nowhere else. Resolution is pure
# data, so a fake `uname` is enough to check it — and it caught a real bug: the
# apt/brew fallback used to run both ways, so a Linux-only package resolved to
# `apt install davfs2` on a machine that has never had apt.
DARWIN=$PWD/fakeuname
drs() { PATH="$DARWIN:$PATH" sh $D resolve "$1" | tr '\t' ' '; }
is "darwin: tap-only pkg is a brew tap" "brew can1357/tap/omp" "$(drs agents/omp)"
is "darwin: zsh is built in" "builtin zsh" "$(drs core/zsh)"
is "darwin: no apt package is not a reason to try apt" "unavailable" \
"$(drs networking/davfs2 | tr -d ' ')"
# Nothing in the default set may be unresolvable — that is a manifest bug, and
# it is silent until someone runs the installer on a fresh machine.
reset
is "every default package resolves" "" \
"$(sh $D plan 2>/dev/null | sed -n '/no source on this platform/,$p' | grep -v 'no source' || true)"
printf '\n\033[1mthe installer — driven against fake package managers\033[0m\n'
FAKE=$PWD/fakebin
export DOTUP_TEST_LOG=$PWD/state/calls.log
# --print resolves everything and must call nothing at all. The fakes are first
# on PATH, so any call whatsoever leaves a trace.
reset
: > "$DOTUP_TEST_LOG"
out=$(PATH="$FAKE:$PATH" sh $D --unattended --print 2>&1 || true)
is "--print calls no package manager" "0" "$(grep -c . "$DOTUP_TEST_LOG" || true)"
has "--print still shows the apt batch" "apt-get install -y" "$out"
has "--print shows the npm batch" "npm install -g" "$out"
hasnt "--print never reaches the private stage" "Bootstrap URL:" "$out"
# Now the real engine, over a subset chosen to exercise every non-destructive
# channel. Deliberately not the whole default set: the tarball and script
# handlers write outside $HOME, and a test suite has no business doing that.
reset
sh $D preset none
cat > state/selected <<'EOF'
agents/omp
agents/specify-cli
apps/obsidian
core/bitwarden-cli
core/gh
core/lazygit
core/mermaid-cli
core/ripgrep
EOF
: > "$DOTUP_TEST_LOG"
PATH="$FAKE:$PATH" sh $D install >/dev/null 2>&1 || true
log=$(cat "$DOTUP_TEST_LOG")
has "apt batches its packages in one call" "apt-get install -y ripgrep" "$log"
has "brew gets the tap-only package" "brew install can1357/tap/omp" "$log"
has "brew gets the apt-less package" "brew install lazygit" "$log"
has "npm gets the scoped spec" "@mermaid-js/mermaid-cli" "$log"
has "uv installs the tool, not the package" "uv tool install specify-cli" "$log"
has "snap gets the renamed binary" "snap install bw" "$log"
has "flatpak gets the app id" "flatpak install" "$log"
has "…with the real obsidian app id" "md.obsidian.Obsidian" "$log"
# The one that matters on Linux: apt not knowing a name is not a dead end.
has "apt probes before it batches" "apt-cache show gh" "$log"
has "an apt name apt rejects moves to brew" "brew install gh" "$log"
hasnt "…and is not left in the apt batch" "apt-get install -y gh" "$log"
# npm cannot run before node, uv cannot run before uv. The pipeline is fixed
# rather than sorted, so assert the order it actually produces.
is "apt runs before npm" "yes" \
"$(a=$(grep -n 'apt-get install' "$DOTUP_TEST_LOG" | head -1 | cut -d: -f1)
b=$(grep -n 'npm install' "$DOTUP_TEST_LOG" | head -1 | cut -d: -f1)
[ -n "$a" ] && [ -n "$b" ] && [ "$a" -lt "$b" ] && echo yes || echo no)"
# Nothing runs as root that does not have to.
hasnt "brew is never run through sudo" "sudo brew" "$log"
printf '\n\033[1munattended — the boundary holds because of what is missing\033[0m\n'
# A stale state file is the adversary here: it ticks the two things an
# unattended run must never act on, and the run has to refuse both anyway.
rm -rf state; mkdir -p state
cat > state/selected <<'EOF'
core/ripgrep
docker/docker-ce
networking/openssh-server
private/bws-secrets
private/private-repo
EOF
: > "$DOTUP_TEST_LOG"
out=$(PATH="$FAKE:$PATH" sh $D --unattended --print 2>&1 || true)
hasnt "unattended never installs a private row" "bws-secrets" "$out"
hasnt "unattended never prompts for a password" "Password:" "$out"
hasnt "unattended refuses a stale invasive tick" "docker-ce" "$out"
hasnt "…including a listening ssh port" "openssh-server" "$out"
has "…but still installs the safe defaults" "ripgrep" "$out"
# Determinism: the same command twice, on the same machine, means the same
# thing. A state file left by an interactive run must not change it.
a=$(PATH="$FAKE:$PATH" sh $D --unattended --print 2>/dev/null || true)
sh $D preset none
b=$(PATH="$FAKE:$PATH" sh $D --unattended --print 2>/dev/null || true)
is "unattended is computed, not inherited" "same" \
"$([ "$a" = "$b" ] && echo same || echo different)"
# `set -e` would kill the subshell at the failing command, so the status is
# captured through a || branch rather than read from $? afterwards.
rc() { c=0; "$@" >/dev/null 2>&1 || c=$?; echo "$c"; }
is "unattended exits clean when nothing fails" "0" \
"$(PATH="$FAKE:$PATH" rc sh $D --unattended --print)"
is "an unknown flag is refused" "2" "$(rc sh $D --nonsense)"
# The negative test in PLAN.md phase 3 runs `zsh -ic exit` after an unattended
# run. That only passes if zsh is in the unattended set.
has "unattended installs the shell it configures" "zsh" \
"$(reset; PATH="$FAKE:$PATH" sh $D --unattended --print 2>&1 | grep 'apt-get install' || true)"
printf '\n\033[1mruns where it has to run\033[0m\n'
# A machine with no sudo and no root is a real case -- a locked-down work box, a
# rootless container. `A || { B && C; }` is one || list, so when `command -v
# sudo` failed the whole list failed and `set -e` killed dotup at load, before
# it printed anything. A minimal PATH is the only way to see that.
# Not under state/ -- reset() removes that whole directory, and a PATH that
# vanishes mid-test looks exactly like the bug being tested for.
MIN=${TMPDIR:-/tmp}/dotup-minbin.$$
rm -rf "$MIN"; mkdir -p "$MIN"
# Resolved by looking, not by `command -v`: an interactive shell can report an
# alias or a bare name for these, and a symlink to a bare name is a loop.
for t in awk grep sed sort cut tr id uname mkdir cp mv rm cat head sh dirname basename; do
for bd in /usr/bin /bin /usr/local/bin; do
[ -x "$bd/$t" ] && { ln -sf "$bd/$t" "$MIN/$t"; break; }
done
done
reset >/dev/null 2>&1
is "runs as a non-root user with no sudo on PATH" "0" \
"$(c=0; env -i HOME="$HOME" PATH="$MIN" DOTUP_STATE="$DOTUP_STATE" DOTUP_MANIFEST="$M" \
"$MIN/sh" $D --unattended --print >/dev/null 2>&1 || c=$?
echo "$c")"
is "…and still resolves the whole default set" "1" \
"$(env -i HOME="$HOME" PATH="$MIN" DOTUP_STATE="$DOTUP_STATE" DOTUP_MANIFEST="$M" \
"$MIN/sh" $D --unattended --print 2>/dev/null | grep -c 'apt-get install -y ')"
rm -rf "$MIN"
# `sudo` must not be glued onto anything that is not a system package manager.
is "sudo is only ever used for apt, snap and dpkg" "" \
"$(grep -oE '\$\{SUDO:\+\$SUDO \}[a-z-]+' $D | sed 's/.*}//' | sort -u \
| grep -vE '^(apt-get|snap|rm|mkdir|tar|ln)$' || true)"
printf '\n\033[1minteractive loop (needs a pty)\033[0m\n'
if command -v fzf >/dev/null && command -v curl >/dev/null && command -v script >/dev/null; then
out=$(sh ./listen-test.sh 2>/dev/null | tr -d '\r' || true)
case $out in
*CURSOR_HELD*) ok "cursor survives toggle+reload" ;;
*) no "cursor position lost across reload — rapid ticking breaks" ;;
esac
else
printf ' \033[33mskip\033[0m needs fzf, curl and script\n'
fi
printf '\n%d passed, %d failed\n\n' "$pass" "$fail"
[ "$fail" -eq 0 ]
+218
View File
@@ -0,0 +1,218 @@
# dotfiles — public tier
Everything a machine needs to become a working machine, and nothing that makes
it *mine*. Clone it anonymously, apply it to a throwaway VM, and you get the
editor, the shell, the terminal and the tools. You do not get my name, my email,
my hosts, my agent config, or any credential — because none of that is in here.
The other half lives in a private repo you cannot clone without a password. That
split is the whole design, and it is structural rather than a policy: the gate
is *which repo a machine can obtain*, not a `.chezmoiignore` rule that could be
misconfigured once and leak.
Why the repo is shaped this way: **`DESIGN.md`**. How it got here: **`PLAN.md`**.
Both live in the working repo, not this one.
---
## Install
```sh
sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply <this repo url>
~/.local/bin/dotup
```
Two commands. The first lays down the files and clones the six externals; the
second picks and installs the software. There is no third step, and nothing
here prompts for anything.
For a machine with nobody at the keyboard:
```sh
dotup --unattended # safe defaults, no UI, no prompt, no private tier
dotup --print # resolve everything and print the commands, install nothing
```
## What lands where
| Path | What |
|---|---|
| `~/.zshrc` | Plain file, no template, no secrets. One guarded `source` is the entire secret surface. |
| `~/.zshenv`, `~/.p10k.zsh` | Shell environment and prompt |
| `~/.config/nvim/**` | LazyVim, 36 files. `lua/plugins/image.lua` is the fragile one — see DESIGN §3 before touching it. |
| `~/.config/{gh,ghostty,git,btop,herdr}` | Terminal and tooling config |
| `~/.tmux.conf` → `~/.tmux/.tmux.conf` | Symlink into the gpakosz external |
| `~/.gitconfig` | No `[user]`. See "the seams" below. |
| `~/.local/bin/{dotup,sysjournal}` | The installer and the journal CLI |
| `~/.local/share/dotup/packages.tsv` | The manifest `dotup` reads |
## The seams
Three files this tier *reads* and never writes. Each is absent on a public-only
machine, and each absence is a silent no-op rather than an error.
| File | Written by | Carries |
|---|---|---|
| `~/.config/git/config.local` | private tier | `[user]`, signing key, Gitea host rewrites |
| `~/.config/zsh/local.zsh` | private tier | aliases naming hosts you own |
| `~/.config/zsh/secrets.zsh` | `dotsecrets`, from bws | the seven API keys, mode 600, in no repo |
If `git commit` says it does not know who you are, the private tier has not been
applied. That is the correct complaint, not a bug.
## Externals
Six `git-repo` externals, none of which were declared anywhere before this repo
existed. That omission is why a clean bootstrap used to produce a broken login
shell: `.zshrc` sourced `$ZSH/oh-my-zsh.sh` with nothing having installed it.
`.tmux`, oh-my-zsh, powerlevel10k, zsh-autosuggestions, zsh-ai, tpm. The theme
and both plugins are nested under `.oh-my-zsh/custom/`, which is what
`$ZSH_CUSTOM` resolves to; chezmoi processes externals in path order, so the
parent is cloned first.
## dotup
One screen. Groups and packages are the same kind of row, both carry a tick box,
and every group opens into the packages underneath it. Group boxes are a
tri-state summary of their children, never an independent switch.
```
▸ [x] core 31/31 safe
▾ [~] media 2/3 safe
[x] ffmpeg apt ffmpeg
[ ] sox apt sox
[x] p7zip apt p7zip-full
▸ [ ] gpu 0/3 invasive kernel modules; a bad driver can bre…
▸ [ ] private 0/2 private one password, typed after the install…
space tick tab open ^t tick all shown ^a defaults ^x none enter install
```
**One toggle rule, everywhere.** Expand the row to the packages it covers; if
every one is on, turn them all off, otherwise turn them all on. That single rule
covers a group row, a package row, and a bulk toggle over a filtered set — type
`nvidia`, press `^t`, and exactly the three rows you can see flip.
**The risk model.** `safe` is pre-ticked; `gui` is pre-ticked only where there
is a display; `invasive` is never ticked for you, and its reason is on the row;
`private` needs a password you type after the install finishes. A group's flag
is derived as the worst flag among its children, so a group can never look
safer than something inside it.
**Dependencies tick themselves.** `@needs` closure runs in both directions:
ticking `xrdp` ticks the `desktop` group it is useless without, and unticking
`core/node` drops `codex`, `pi`, `mermaid-cli` and then `neovim`. The closure is
shown, not described — the counts move on the same keystroke.
### The manifest
Five tab-separated columns, greppable by hand. Anything that would have been a
sixth column is an `@` directive instead:
```tsv
@needs core/neovim core/imagemagick core/mermaid-cli
@spec agents/codex @openai/codex
core neovim safe -tarball neovim apt ships 0.9.5 — tarball to /opt/nvim
core fd safe fd-find fd binary is fdfind on ubuntu
```
A leading `-` in the apt or brew column means "not from this package manager":
`-tarball -npm -script -snap -deb -flatpak -uv -builtin -xcode`. A bare `-`
means unavailable there — and **Linux resolves apt-then-brew, not
apt-instead-of-brew**, because `omp`, `herdr` and `lazygit` have no apt package
at all. The installer goes further and probes `apt-cache` before it batches: a
name apt does not know moves to the brew column rather than failing the whole
batch.
`@spec` carries the argument a channel needs but the package name cannot supply
— the scoped npm name, the flatpak app id, the `.deb` source. It defaults to the
package name.
### Where fzf comes from
The picker cannot get fzf from the manifest: it needs fzf to *draw* the
manifest. So it brings its own copy into `~/.cache/dotup/` and invokes it by
absolute path. **`PATH` is never modified and `~/.local/bin` is never written**
— a tool that refuses to tick `invasive` for you has no business quietly
shadowing your distro's fzf for Ctrl-R and the oh-my-zsh plugin. Your own fzf
wins whenever it clears the floor; nothing is replaced merely for being old.
Floor is 0.44.0, which is exactly where verification stops rather than where
compatibility breaks: every release from 0.29 up parses every binding used here,
but cursor-on-reload can only be *measured* from 0.44.1, where fzf's `--listen`
API began reporting state.
### The private tier
Ticking a `private` row schedules work, it does not do it — `git`, `chezmoi` and
`bws` have to exist first, and a password typed at picker time would sit in
memory through ten minutes of package downloads. So it happens at the end, and
never without a human:
```
prompt: URL · username · password the address is in no repo
│
├── blank ─▶ done. public-only machine. nothing was asked for.
├── 401 ─▶ say so, leave the machine public-only
└── 200 ─▶ two-line blob ─┬─ private repo → chezmoi init --apply
└─ bws token → ~/.config/bitwarden/bws-token (600)
```
The endpoint returns **data, never a script**: two `KEY=VALUE` lines,
`PRIVATE_REPO_URL=` and `BWS_ACCESS_TOKEN=`. Compromising it discloses two
revocable read-only credentials rather than executing arbitrary code as you on
every machine you ever build. The password is spent, never stored, and never
reaches `argv` — `curl -K -` reads it from stdin, so it never reaches `ps`
either.
`dotup --unattended` cannot reach any of this. It has nobody to type a password,
and it refuses `private` and `invasive` rows outright even if a stale state file
ticks them.
## Tests
```sh
sh .tests/test.sh # here
sh .tests/test.sh --docker # clean ubuntu:24.04
DOTUP_TEST_IMAGE=ubuntu:22.04 sh .tests/test.sh --docker
DOTUP_TEST_IMAGE=debian:12 sh .tests/test.sh --docker
```
81 assertions covering the toggle rule, the `@needs` closure both ways, the risk
model as invariants rather than prose, match confinement (two-sided: `--exact`
confines `nvidia` to three rows **and** fuzzy still over-matches, so removing
`--exact` fails loudly), the fzf preflight, channel resolution on both
platforms, the installer, and that dotup runs at all as a non-root user with no
`sudo` on `PATH`.
The installer is driven against a directory of fake package managers that record
what they were asked to do and install nothing, so the engine is exercised end
to end without a single package landing on the machine running the suite.
The version spread is the point of the containers — this box runs fzf 0.72,
24.04 ships 0.44.1, 22.04 ships 0.29 and bookworm 0.38.
The macOS half of the manifest has no CI anywhere, so a fake `uname` stands in
for it. That is not theatre: it caught the apt/brew fallback running in both
directions, which resolved Linux-only packages to `apt install davfs2` on a
machine that has never had apt. The fallback is one-directional now.
## Rules this repo lives by
- **Zero credentials, forever.** `gitleaks detect` runs over the full history
with no allowlist. The private repo gets exactly one allowlisted path; this one
gets none, and that asymmetry is the point.
- **Grep the files this repo owns, not `$HOME`.** A wholesale scan of a finished
machine is not a test of this repo: `~/.npm-global` ends up holding an AWS SDK
whose type definitions contain a literal `AKIA…` and an Anthropic SDK whose
docs contain `ghp_…`. Scope the scan to `chezmoi managed`, and tighten `sk-` to
`sk-[A-Za-z0-9_-]{20,}` — the bare substring matches `ask-`, `task-`, `risk-`
and `disk-`, and one of this manifest's own npm packages is called
`rpiv-ask-user-question`.
- **No identity.** No name, email, hostname, private IP or absolute home path.
If something needs one, it belongs behind a seam.
- **`.chezmoiignore` is gitignore syntax.** A `#` mid-line becomes part of the
pattern and silently matches nothing. Every comment goes on its own line.
- **Nothing invasive without an action of yours.** Not "never", since a `@needs`
edge can pull one in — but never silently, and never unattended.
+26
View File
@@ -0,0 +1,26 @@
; ~/.gitconfig — PUBLIC tier.
;
; Nothing here identifies you. Your name, your email, your signing key and the
; Gitea host rewrites are identity, not configuration, so they live in the
; private tier — which writes ~/.config/git/config.local. The include below is
; the seam between the two. Git treats a missing include as a no-op, so a
; public-only machine reads this file and stops.
;
; Do not add [user] here. If `git commit` complains that it does not know who
; you are, the private tier has not been applied, and that is the correct
; complaint.
[init]
defaultBranch = main
[filter "lfs"]
clean = git-lfs clean -- %f
smudge = git-lfs smudge -- %f
process = git-lfs filter-process
required = true
{{ if eq .chezmoi.os "darwin" }}
[credential]
helper = manager
{{ end }}
[include]
path = ~/.config/git/config.local
+893
View File
@@ -0,0 +1,893 @@
#!/bin/sh
# dotup — the front door. One command from a bare machine to a finished one.
#
# It is a picker and an installer, not a picker next to an installer. Tick boxes
# on groups AND on individual packages; one toggle rule everywhere: expand the
# row to its leaves, and if every leaf is on turn them all off, otherwise turn
# them all on. That single rule covers a group row, a package row, and a bulk
# toggle over a filtered set.
#
# dotup the picker, then install what you ticked
# dotup --unattended no UI: safe defaults, never prompts, never private
# dotup --print resolve and print every command, install nothing
#
# POSIX sh on purpose: this runs on a bare box before anything is installed,
# and macOS still ships bash 3.2 (no associative arrays).
set -eu
SELF=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)/$(basename -- "$0")
HERE=$(dirname -- "$SELF")
STATE=${DOTUP_STATE:-${XDG_CONFIG_HOME:-$HOME/.config}/dotfiles}
SEL=$STATE/selected
EXP=$STATE/expanded
# The manifest is data, not a script, so it does not live in bin/. Checked in
# the development layout first so the repo's own test suite and a checkout both
# work without setting anything.
if [ -n "${DOTUP_MANIFEST:-}" ]; then MANIFEST=$DOTUP_MANIFEST
elif [ -f "$HERE/packages.tsv" ]; then MANIFEST=$HERE/packages.tsv
else MANIFEST=${XDG_DATA_HOME:-$HOME/.local/share}/dotup/packages.tsv
fi
[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && HAS_DISPLAY=1 || HAS_DISPLAY=0
case $(uname -s) in Darwin) PLAT=brew ;; *) PLAT=apt ;; esac
UNATTENDED=0
DRYRUN=0
ASSUME_YES=0
mkdir -p "$STATE"
[ -f "$SEL" ] || : > "$SEL"
[ -f "$EXP" ] || : > "$EXP"
R=''; DIM=''; B=''; GRN=''; YEL=''; RED=''
if [ -t 1 ]; then
R=$(printf '\033[0m'); DIM=$(printf '\033[2m'); B=$(printf '\033[1m')
GRN=$(printf '\033[32m'); YEL=$(printf '\033[33m'); RED=$(printf '\033[31m')
fi
say() { printf '%s\n' "$*" >&2; }
warn() { printf '%s!%s %s\n' "$YEL" "$R" "$*" >&2; }
err() { printf '%s✗%s %s\n' "$RED" "$R" "$*" >&2; }
head_() { printf '\n%s%s%s\n' "$B" "$*" "$R" >&2; }
# ---------------------------------------------------------------- leaves ----
# Expand a row key to the package keys it covers.
# g:agents -> agents/codex agents/pi ...
# p:agents/pi -> agents/pi
leaves() {
for key in "$@"; do
case $key in
g:*) grp=${key#g:}
awk -F'\t' -v g="$grp" '!/^[#@]/ && NF>=3 && $1==g {print $1"/"$2}' "$MANIFEST" ;;
p:*) printf '%s\n' "${key#p:}" ;;
esac
done
}
# ------------------------------------------------------------------ deps ----
# Dependencies are @needs directives, not a sixth column: the TSV stays five
# wide and greppable, and a package with no dependencies costs nothing to read.
#
# @needs <group/pkg> <dep> [dep ...] dep is group/pkg or a whole group
#
# There is no "requires" state to display. Ticking a box ticks what it needs;
# unticking one unticks what needed it. The counts on screen move as it happens,
# so the closure is visible rather than described.
# stdin: keys -> stdout: those keys plus everything they need, transitively.
expand_deps() {
work=$(sort -u); prev=
while [ "$work" != "$prev" ]; do
prev=$work
add=$(printf '%s\n' "$work" | while read -r k; do
[ -n "$k" ] || continue
awk -F'\t' -v k="$k" '$1=="@needs" && $2==k {print $3}' "$MANIFEST"
done | tr ' ' '\n' | while read -r d; do
[ -n "$d" ] || continue
case $d in
*/*) printf '%s\n' "$d" ;;
*) awk -F'\t' -v g="$d" '!/^[#@]/ && NF>=3 && $1==g {print $1"/"$2}' "$MANIFEST" ;;
esac
done)
work=$(printf '%s\n%s\n' "$work" "$add" | grep . | sort -u)
done
printf '%s\n' "$work"
}
# stdin: keys -> stdout: those keys plus everything that needs them.
expand_rdeps() {
work=$(sort -u); prev=
while [ "$work" != "$prev" ]; do
prev=$work
add=$(printf '%s\n' "$work" | while read -r k; do
[ -n "$k" ] || continue
awk -F'\t' -v k="$k" -v g="${k%%/*}" '$1=="@needs"{
n=split($3, d, " ")
for(i=1;i<=n;i++) if (d[i]==k || d[i]==g) print $2 }' "$MANIFEST"
done)
work=$(printf '%s\n%s\n' "$work" "$add" | grep . | sort -u)
done
printf '%s\n' "$work"
}
# ---------------------------------------------------------------- toggle ----
# All on -> all off. Anything else -> all on.
cmd_toggle() {
want=$(leaves "$@" | sort -u)
[ -n "$want" ] || return 0
all_on=1
for k in $want; do grep -qxF "$k" "$SEL" || { all_on=0; break; }; done
# Decide direction on what you touched, then widen along the dependency
# edges: switching on pulls in what it needs, switching off drops what
# needed it. Either way the ticks never describe a broken machine.
if [ "$all_on" -eq 1 ]; then want=$(printf '%s\n' "$want" | expand_rdeps)
else want=$(printf '%s\n' "$want" | expand_deps); fi
tmp=$STATE/.sel.$$
grep -vxF -f - "$SEL" > "$tmp" <<-EOF || :
$want
EOF
if [ "$all_on" -eq 0 ]; then printf '%s\n' "$want" >> "$tmp"; fi
sort -u "$tmp" > "$SEL" && rm -f "$tmp"
}
cmd_expand() {
for key in "$@"; do
case $key in g:*) g=${key#g:} ;; p:*) g=${key#p:}; g=${g%%/*} ;; *) continue ;; esac
tmp=$STATE/.exp.$$
if grep -qxF "$g" "$EXP"; then grep -vxF "$g" "$EXP" > "$tmp" || :
else cp "$EXP" "$tmp"; printf '%s\n' "$g" >> "$tmp"; fi
mv "$tmp" "$EXP"
done
}
cmd_expand_all() {
if [ -s "$EXP" ]; then : > "$EXP"
else awk -F'\t' '!/^[#@]/ && NF>=3 {print $1}' "$MANIFEST" | awk '!seen[$0]++' > "$EXP"; fi
}
# --------------------------------------------------------------- presets ----
cmd_preset() {
case ${1:-defaults} in
none) : > "$SEL" ;;
safe) awk -F'\t' '!/^[#@]/ && NF>=3 && $3=="safe" {print $1"/"$2}' "$MANIFEST" > "$SEL" ;;
defaults)
awk -F'\t' -v d="$HAS_DISPLAY" \
'!/^[#@]/ && NF>=3 && ($3=="safe" || ($3=="gui" && d==1)) {print $1"/"$2}' \
"$MANIFEST" > "$SEL" ;;
esac
}
# ---------------------------------------------------------------- render ----
cmd_render() {
awk -F'\t' -v selfile="$SEL" -v expfile="$EXP" -v plat="$PLAT" -v disp="$HAS_DISPLAY" '
function sev(f){ return f=="invasive"?3 : f=="private"?2 : f=="gui"?1 : 0 }
function lbl(s){ return s==3?"invasive" : s==2?"private" : s==1?"gui" : "safe" }
function col(s){ return s==3?RED : s==2?MAG : s==1?CYA : GRN }
# Linux is apt-then-brew, not apt-instead-of-brew: all three machines run
# linuxbrew, and omp/herdr/lazygit exist only as taps.
# The fallback runs one way only. Linux is apt-then-brew because all three
# machines run linuxbrew and omp/herdr/lazygit exist only as taps. There is
# no apt on a Mac, so a bare `-` in the brew column means unavailable — the
# other direction would offer to `apt install davfs2` on macOS.
function src(a,b, v,p){ if (plat=="brew") { v=b; p="brew" }
else { v=a; p="apt"; if (v=="-") { v=b; p="brew" } }
if (v=="-") return "unavailable"
if (substr(v,1,1)=="-") return substr(v,2)
return p" "v }
function cut(s,n){ return length(s)>n ? substr(s,1,n-1) "\342\200\246" : s }
BEGIN{
R="\033[0m"; DIM="\033[2m"; B="\033[1m"
GRN="\033[32m"; YEL="\033[33m"; RED="\033[31m"; CYA="\033[36m"; MAG="\033[35m"
while((getline l < selfile) > 0) sel[l]=1
while((getline l < expfile) > 0) expd[l]=1
}
# @group<TAB>note — a group whose members fail the safe test for several
# different reasons needs its own line. Deriving it from the first child
# would show "opens port 22" on a group that also mounts setuid helpers.
$1=="@needs" || $1=="@spec" { next }
/^@/ { gnote[substr($1,2)] = $2; next }
/^#/ || NF<3 { next }
{
g=$1; p=$2; f=$3; key=g"/"p
n++; G[n]=g; P[n]=p; F[n]=f; A[n]=$4; Bc[n]=$5; NT[n]=$6
if (!(g in seen)) { seen[g]=1; order[++ng]=g }
tot[g]++
if (key in sel) { selc[g]++; on[n]=1 }
if (sev(f) > worst[g]) worst[g]=sev(f)
if (f=="invasive" || f=="private") why[g]=($6!="" && why[g]=="") ? $6 : why[g]
}
END{
for (i=1; i<=ng; i++) {
g=order[i]; s=worst[g]; c=selc[g]+0; t=tot[g]
mark = (c==t) ? "x" : (c>0 ? "~" : " ")
mc = (c==t) ? GRN : (c>0 ? YEL : DIM)
arrow = (g in expd) ? "\342\226\276" : "\342\226\270"
reason = (g in gnote) ? gnote[g] : why[g]
note = (s>=2 && reason!="") ? " " col(s) reason R : ""
printf "%s %s[%s]%s %s%-13s%s %s%5s%s %s%-8s%s%s\t%s\n",
arrow, mc, mark, R, B, g, R, DIM, c"/"t, R, col(s), lbl(s), R, cut(note,44), "g:" g
if (!(g in expd)) continue
for (j=1; j<=n; j++) {
if (G[j]!=g) continue
m = (j in on) ? "x" : " "
mc = (j in on) ? GRN : DIM
d = src(A[j], Bc[j])
if (NT[j]!="") d = d " \302\267 " NT[j]
printf " %s[%s]%s %-18s %s%s%s\t%s\n",
mc, m, R, P[j], DIM, cut(d,46), R, "p:" g "/" P[j]
}
}
}' "$MANIFEST"
}
# --------------------------------------------------------------- explain ----
cmd_explain() {
key=${1:-}
awk -F'\t' -v key="$key" -v selfile="$SEL" -v plat="$PLAT" '
function src(a,b, v,p){ if (plat=="brew") { v=b; p="brew" }
else { v=a; p="apt"; if (v=="-") { v=b; p="brew" } }
if (v=="-") return "not available"
if (substr(v,1,1)=="-") return substr(v,2)
return p " install " v }
BEGIN{
R="\033[0m"; DIM="\033[2m"; B="\033[1m"; RED="\033[31m"; GRN="\033[32m"
while((getline l < selfile) > 0) sel[l]=1
split(key, kk, ":"); kind=kk[1]; rest=substr(key, index(key,":")+1)
if (kind=="g") { wantg=rest } else { split(rest, pp, "/"); wantg=pp[1]; wantp=pp[2] }
}
# A key may carry several @needs lines; keep them all, not just the last.
$1=="@needs" { if ($2==rest) needs = needs (needs==""?"":" ") $3; next }
$1=="@spec" { if ($2==rest) spec=$3; next }
/^@/ { if (substr($1,2)==wantg) gnote=$2; next }
/^#/ || NF<3 { next }
$1==wantg {
if (wantp=="" ) {
t++; if (($1"/"$2) in sel) c++
list = list sprintf(" %s %s\n", (($1"/"$2) in sel)?GRN "\342\234\223" R:DIM "\342\227\246" R, $2)
if ($6!="" && flagnote=="") flagnote=$6
f=$3
} else if ($2==wantp) {
printf "%s%s%s\n\n", B, $2, R
printf "%sgroup%s %s\n", DIM, R, $1
printf "%srisk%s %s\n", DIM, R, $3
printf "%sapt%s %s\n", DIM, R, ($4=="-"?"not available":$4)
printf "%sbrew%s %s\n", DIM, R, ($5=="-"?"not available":$5)
printf "%sinstall%s %s\n", DIM, R, src($4,$5)
if ($6!="") printf "\n%s\n", $6
done=1
}
}
END{
if (done) {
if (spec!="") printf "\n%sspec%s %s\n", DIM, R, spec
if (needs!="") printf "\n%sneeds%s %s%s\n", DIM, R, needs, DIM " (ticked automatically)" R
printf "\n%sstate%s %s\n", DIM, R, (key_in_sel()) ? GRN "selected" R : DIM "not selected" R
exit
}
printf "%s%s%s %s%d of %d selected%s\n\n", B, wantg, R, DIM, c, t, R
printf "%s\n", list
if (f=="invasive") printf "%sinvasive%s %s\n", RED, R, (gnote!="" ? gnote : flagnote)
if (f=="private") printf "%s\n", (gnote!="" ? gnote : "one password, typed after the install finishes")
}
function key_in_sel(){ return (rest in sel) }
' "$MANIFEST"
}
# ------------------------------------------------------------- resolution ----
# One place decides, for a selected package on this platform, what actually
# installs it. Everything downstream — the plan, the installer, the dry run —
# reads this and nothing else, so they cannot disagree.
#
# Output: <channel><TAB><argument>
# apt|brew argument is the package name
# npm|uv|snap|deb|flatpak argument is the @spec, defaulting to the pkg name
# tarball|script|xcode|builtin bespoke by nature; dispatched on the key
# unavailable neither column offers it here
resolve() {
awk -F'\t' -v key="$1" -v plat="$PLAT" '
$1=="@spec" { if ($2==key) spec=$3; next }
/^[#@]/ || NF<3 { next }
($1"/"$2)==key { pkg=$2; apt=$4; brw=$5; found=1 }
END{
if (!found) { print "missing\t"; exit }
# apt-then-brew, not apt-instead-of-brew: omp, herdr and lazygit have no
# apt package at all and install perfectly well from linuxbrew. The
# fallback is one-directional on purpose — there is no apt on a Mac, so
# a bare `-` in the brew column is the end of the road, not a reason to
# go looking in a column that names Debian packages.
if (plat=="brew") { v=brw; p="brew" }
else { v=apt; p="apt"; if (v=="-") { v=brw; p="brew" } }
if (v=="-") { print "unavailable\t"; exit }
if (substr(v,1,1)=="-") { print substr(v,2) "\t" (spec!="" ? spec : pkg); exit }
print p "\t" v
}' "$MANIFEST"
}
# Every selected key, minus the private rows — those are not packages.
selected_packages() {
awk -F'\t' -v selfile="$SEL" '
BEGIN{ while((getline l < selfile)>0) sel[l]=1 }
/^[#@]/ || NF<3 { next }
$3=="private" { next }
($1"/"$2) in sel { print $1"/"$2 }' "$MANIFEST"
}
selected_private() {
awk -F'\t' -v selfile="$SEL" '
BEGIN{ while((getline l < selfile)>0) sel[l]=1 }
/^[#@]/ || NF<3 { next }
$3=="private" && (($1"/"$2) in sel) { print $1"/"$2 }' "$MANIFEST"
}
# Build <channel><TAB><arg><TAB><key> for everything selected.
plan_table() {
selected_packages | while read -r k; do
[ -n "$k" ] || continue
printf '%s\t%s\n' "$(resolve "$k")" "$k"
done
}
cmd_plan() {
head_ "plan"
plan_table | sort | awk -F'\t' '
BEGIN{ R="\033[0m"; B="\033[1m"; DIM="\033[2m"; YEL="\033[33m" }
{ ch=$1; arg=$2; key=$3
if (ch=="unavailable" || ch=="missing") { bad = bad " " key "\n"; nb++; next }
line[ch] = line[ch] " " sprintf("%-28s %s", key, arg) "\n"; n++ }
END{
for (c in line) printf " %s%s%s\n%s", B, c, R, line[c]
printf "\n %d packages\n", n
if (nb) printf "\n %sno source on this platform (%d):%s\n%s", YEL, nb, R, bad
}'
}
# --------------------------------------------------------------- installer ---
# Nothing here is clever. It batches what can be batched, refuses what it cannot
# reach, and never lets one bad package sink the other thirty.
# Written as an `if`, not `A || { B && C; }`: that form is one || list, so when
# `command -v sudo` fails the whole list fails and `set -e` exits the script at
# load. On a non-root machine with no sudo, dotup died before printing anything.
SUDO=
if [ "$(id -u)" != 0 ] && command -v sudo >/dev/null 2>&1; then SUDO=sudo; fi
FAILED=$STATE/.failed.$$
APT_UPDATED=0
have() { find_tool "$1" >/dev/null 2>&1; }
# A tool installed a moment ago is not on this process's PATH: the astral
# installer drops uv in ~/.local/bin, npm -g honours the ~/.npmrc prefix, the go
# tarball lands in /usr/local/go, and linuxbrew lives outside a non-login PATH.
# The fix is to look in the places we just wrote to -- NOT to export a modified
# PATH. A tool that refuses to shadow your fzf has no business rewriting PATH
# for its own convenience either, and a child process's PATH would be a lie the
# moment dotup exits.
find_tool() {
command -v "$1" 2>/dev/null && return 0
for ft_c in "$HOME/.local/bin/$1" "$HOME/.npm-global/bin/$1" \
"/usr/local/bin/$1" "/usr/local/go/bin/$1" \
"/home/linuxbrew/.linuxbrew/bin/$1" "/opt/homebrew/bin/$1"; do
[ -x "$ft_c" ] && { printf '%s\n' "$ft_c"; return 0; }
done
return 1
}
# Collapse a space-separated list to canonical form; an all-blank list becomes
# empty, so a channel with nothing in it prints no header.
norm() { printf '%s\n' "$*" | tr ' ' '\n' | grep . | tr '\n' ' ' | sed 's/ $//'; }
run() {
if [ "$DRYRUN" -eq 1 ]; then printf ' + %s\n' "$*"; return 0; fi
printf '%s + %s%s\n' "$DIM" "$*" "$R" >&2
"$@"
}
run_sh() {
if [ "$DRYRUN" -eq 1 ]; then printf ' + %s\n' "$1"; return 0; fi
printf '%s + %s%s\n' "$DIM" "$1" "$R" >&2
sh -c "$1"
}
note_fail() { printf '%s\t%s\n' "$1" "$2" >> "$FAILED"; err "$1: $2"; }
apt_update_once() {
[ "$APT_UPDATED" -eq 0 ] || return 0
APT_UPDATED=1
run_sh "${SUDO:+$SUDO }apt-get update -qq" || warn "apt-get update failed; continuing with stale lists"
}
# apt refuses the whole batch when one name is unknown, so ask first. A name apt
# does not know is not a dead end: if the brew column offers it, it moves there.
# That is what "apt-then-brew" has to mean in practice, and it is the difference
# between 34 packages installed and 0.
apt_known() { apt-cache show "$1" >/dev/null 2>&1; }
install_apt() {
pkgs=$(norm "$1")
[ -n "$pkgs" ] || return 0
head_ "apt"
apt_update_once
keep=; moved=; unknown=
for p in $pkgs; do
if [ "$DRYRUN" -eq 1 ] || ! have apt-cache || apt_known "$p"; then keep="$keep $p"
else unknown="$unknown $p"; fi
done
for p in $unknown; do
k=$(awk -F'\t' -v p="$p" '!/^[#@]/ && NF>=3 && index(" "$4" "," "p" ") {print $1"/"$2; exit}' "$MANIFEST")
b=$(awk -F'\t' -v p="$p" '!/^[#@]/ && NF>=3 && index(" "$4" "," "p" ") {print $5; exit}' "$MANIFEST")
case $b in
-|-*) note_fail "${k:-$p}" "apt does not know '$p' and there is no brew fallback" ;;
*) warn "apt does not know '$p' — falling back to brew '$b'"; moved="$moved $b" ;;
esac
done
if [ -n "$keep" ]; then
# shellcheck disable=SC2086
if ! run_sh "${SUDO:+$SUDO }DEBIAN_FRONTEND=noninteractive apt-get install -y$(printf ' %s' $keep)"; then
warn "batch install failed; retrying one at a time so one bad package does not sink the rest"
for p in $keep; do
run_sh "${SUDO:+$SUDO }DEBIAN_FRONTEND=noninteractive apt-get install -y $p" \
|| note_fail "$p" "apt install failed"
done
fi
fi
BREW_EXTRA=$moved
}
install_brew() {
pkgs=$(norm "$1")
[ -n "$pkgs" ] || return 0
head_ "brew"
BREW=$(find_tool brew || echo brew)
if ! have brew && [ "$DRYRUN" -eq 0 ]; then
# Installing a second package manager is exactly the kind of thing the
# `invasive` flag exists to refuse doing on your behalf. Say what to run.
warn "brew is not installed; skipping:$pkgs"
warn " install it first: /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\""
for p in $pkgs; do note_fail "$p" "brew missing"; done
return 0
fi
for p in $pkgs; do run_sh "$BREW install $p" || note_fail "$p" "brew install failed"; done
}
install_npm() {
specs=$(norm "$1")
[ -n "$specs" ] || return 0
head_ "npm"
NPM=$(find_tool npm || echo npm)
if ! have npm && [ "$DRYRUN" -eq 0 ]; then
# Ubuntu's `nodejs` package ships node WITHOUT npm; the manifest asks
# apt for both. If this fires anyway, node itself did not land.
for p in $specs; do note_fail "$p" "npm missing — core/node did not install"; done
return 0
fi
# shellcheck disable=SC2086
run_sh "$NPM install -g$(printf ' %s' $specs)" || {
warn "batch npm install failed; retrying one at a time"
for p in $specs; do run_sh "$NPM install -g $p" || note_fail "$p" "npm install failed"; done
}
}
install_uv() {
tools=$(norm "$1")
[ -n "$tools" ] || return 0
head_ "uv"
UV=$(find_tool uv || echo uv)
if ! have uv && [ "$DRYRUN" -eq 0 ]; then
for t in $tools; do note_fail "$t" "uv missing — core/uv did not install"; done
return 0
fi
for t in $tools; do run_sh "$UV tool install $t" || note_fail "$t" "uv tool install failed"; done
}
install_snap() {
names=$(norm "$1")
[ -n "$names" ] || return 0
head_ "snap"
if ! have snap && [ "$DRYRUN" -eq 0 ]; then
for n in $names; do note_fail "$n" "snapd is not present on this machine"; done
return 0
fi
for n in $names; do run_sh "${SUDO:+$SUDO }snap install $n" || note_fail "$n" "snap install failed"; done
}
install_flatpak() {
ids=$(norm "$1")
[ -n "$ids" ] || return 0
head_ "flatpak"
if ! have flatpak && [ "$DRYRUN" -eq 0 ]; then
for i in $ids; do note_fail "$i" "flatpak is not present on this machine"; done
return 0
fi
for i in $ids; do
run_sh "flatpak install -y --noninteractive flathub $i" || note_fail "$i" "flatpak install failed"
done
}
# Two forms, because two real packages need two different things:
# https://…/x.deb a stable vendor URL (chrome)
# gh:<owner>/<repo>:<asset substring> latest release asset (ghostty)
install_deb() {
srcs=$(norm "$1")
[ -n "$srcs" ] || return 0
head_ "deb"
for s in $srcs; do
url=$s
case $s in
gh:*)
spec=${s#gh:}; repo=${spec%%:*}; match=${spec#*:}
if [ "$DRYRUN" -eq 1 ]; then
printf ' + resolve latest %s asset matching *%s*\n' "$repo" "$match"
url="https://github.com/$repo/releases/latest/<asset matching *$match*>"
else
url=$(curl -fsSL "https://api.github.com/repos/$repo/releases/latest" 2>/dev/null \
| awk -F'"' -v m="$match" '/browser_download_url/ && index($4,m) {print $4; exit}')
[ -n "$url" ] || { note_fail "$repo" "no release asset matching *$match*"; continue; }
fi ;;
esac
f=${TMPDIR:-/tmp}/dotup-$$.deb
run_sh "curl -fsSL '$url' -o '$f'" || { note_fail "$url" "download failed"; continue; }
run_sh "${SUDO:+$SUDO }apt-get install -y '$f'" || note_fail "$url" "dpkg install failed"
run_sh "rm -f '$f'"
done
}
# Bespoke by nature: each of these needs arch detection, a destination outside
# $HOME, and a symlink. A data column cannot express that honestly, so the
# dispatch is on the key and the code says what it does.
install_bespoke() {
bsp=$(norm "$2")
[ -n "$bsp" ] || return 0
head_ "$1"
for key in $bsp; do
case $key in
core/neovim)
# The one place the channel is prescribed rather than "whatever the
# package manager has": 24.04's apt candidate is 0.9.5 and LazyVim
# needs 0.12 for the kitty graphics protocol.
if have nvim && [ "$DRYRUN" -eq 0 ]; then
v=$(nvim --version 2>/dev/null | awk 'NR==1{print $2}' | tr -d 'v')
case $v in 0.1[2-9]*|0.[2-9]*|[1-9]*) say " nvim $v already above 0.12 — leaving it"; continue ;; esac
fi
case $(uname -m) in
x86_64|amd64) a=x86_64 ;;
aarch64|arm64) a=arm64 ;;
*) note_fail "$key" "no neovim tarball for $(uname -m)"; continue ;;
esac
b=https://github.com/neovim/neovim/releases/latest/download
run_sh "curl -fsSL '$b/nvim-linux-$a.tar.gz' -o /tmp/nvim.tgz || curl -fsSL '$b/nvim-linux64.tar.gz' -o /tmp/nvim.tgz" \
|| { note_fail "$key" "tarball download failed"; continue; }
run_sh "${SUDO:+$SUDO }rm -rf /opt/nvim && ${SUDO:+$SUDO }mkdir -p /opt/nvim && ${SUDO:+$SUDO }tar -xzf /tmp/nvim.tgz -C /opt/nvim --strip-components=1" \
|| { note_fail "$key" "tarball extract failed"; continue; }
run_sh "${SUDO:+$SUDO }ln -sf /opt/nvim/bin/nvim /usr/local/bin/nvim"
run_sh "rm -f /tmp/nvim.tgz" ;;
core/go)
if have go && [ "$DRYRUN" -eq 0 ]; then say " go already present — leaving it"; continue; fi
case $(uname -s) in Darwin) o=darwin ;; *) o=linux ;; esac
case $(uname -m) in x86_64|amd64) a=amd64 ;; aarch64|arm64) a=arm64 ;;
*) note_fail "$key" "no go tarball for $(uname -m)"; continue ;; esac
if [ "$DRYRUN" -eq 1 ]; then v='go1.X.Y'
else v=$(curl -fsSL 'https://go.dev/VERSION?m=text' 2>/dev/null | head -1); fi
[ -n "$v" ] || { note_fail "$key" "could not resolve the current go version"; continue; }
run_sh "curl -fsSL 'https://go.dev/dl/$v.$o-$a.tar.gz' -o /tmp/go.tgz" \
|| { note_fail "$key" "tarball download failed"; continue; }
run_sh "${SUDO:+$SUDO }rm -rf /usr/local/go && ${SUDO:+$SUDO }tar -xzf /tmp/go.tgz -C /usr/local" \
|| { note_fail "$key" "tarball extract failed"; continue; }
run_sh "rm -f /tmp/go.tgz" ;;
core/chezmoi)
# Circular by nature: dotup arrives *via* chezmoi. Present already
# in every case that matters; here for the one where it is not.
if have chezmoi && [ "$DRYRUN" -eq 0 ]; then say " chezmoi already present — leaving it"; continue; fi
run_sh "sh -c \"\$(curl -fsLS get.chezmoi.io)\" -- -b \"\$HOME/.local/bin\"" \
|| note_fail "$key" "installer failed" ;;
core/uv)
if have uv && [ "$DRYRUN" -eq 0 ]; then say " uv already present — leaving it"; continue; fi
run_sh "curl -LsSf https://astral.sh/uv/install.sh | sh" || note_fail "$key" "installer failed" ;;
core/build-tools)
# macOS only — the Linux side is build-essential through apt.
if [ "$DRYRUN" -eq 0 ] && xcode-select -p >/dev/null 2>&1; then
say " Xcode command line tools already installed"; continue
fi
run_sh "xcode-select --install" || note_fail "$key" "xcode-select --install failed" ;;
core/zsh|networking/openssh-server)
say " $key: built into macOS, nothing to install" ;;
*) note_fail "$key" "no handler for channel '$1'" ;;
esac
done
}
cmd_install() {
: > "$FAILED"
tbl=$STATE/.plan.$$
plan_table > "$tbl"
# Two filters, and they are the reason this is safe to run unattended.
# `private` is never a package: it needs a password nobody is there to type.
# `invasive` is never installed by a run with nobody at the keyboard, even
# if a stale state file says otherwise — the boundary holds because of what
# this refuses, not because of what it was asked.
if [ "$UNATTENDED" -eq 1 ]; then
inv=$(awk -F'\t' -v selfile="$SEL" '
BEGIN{ while((getline l < selfile)>0) sel[l]=1 }
!/^[#@]/ && NF>=3 && $3=="invasive" && (($1"/"$2) in sel) {print $1"/"$2}' "$MANIFEST")
if [ -n "$inv" ]; then
warn "unattended: refusing invasive packages$(printf ' %s' $inv)"
for k in $inv; do
awk -F'\t' -v k="$k" '$3!=k' "$tbl" > "$tbl.f"; mv "$tbl.f" "$tbl"
done
fi
fi
col() { awk -F'\t' -v c="$1" '$1==c {print $2}' "$tbl" | sort -u | tr '\n' ' '; }
keys() { awk -F'\t' -v c="$1" '$1==c {print $3}' "$tbl" | sort -u | tr '\n' ' '; }
bad=$(awk -F'\t' '$1=="unavailable" || $1=="missing" {print $3}' "$tbl" | tr '\n' ' ')
[ -z "$bad" ] || warn "no source on this platform:$bad"
# Order is a fixed pipeline, not a topological sort, because the real
# manifest has exactly two ordering constraints and both are channel-level:
# npm needs node (apt/brew), and uv tools need uv (script).
BREW_EXTRA=
install_apt "$(col apt)"
install_brew "$(col brew) $BREW_EXTRA"
install_bespoke script "$(keys script)"
install_bespoke tarball "$(keys tarball)"
install_bespoke builtin "$(keys builtin)"
install_bespoke xcode "$(keys xcode)"
install_snap "$(col snap)"
install_deb "$(col deb)"
install_flatpak "$(col flatpak)"
install_npm "$(col npm)"
install_uv "$(col uv)"
rm -f "$tbl"
if [ -s "$FAILED" ]; then
head_ "did not install"
while IFS=" " read -r what why; do printf ' %s%-32s%s %s\n' "$RED" "$what" "$R" "$why" >&2; done < "$FAILED"
n=$(grep -c . "$FAILED")
rm -f "$FAILED"
printf '\n%s%d package(s) did not install.%s Everything else did.\n' "$YEL" "$n" "$R" >&2
return 1
fi
rm -f "$FAILED"
head_ "done"
return 0
}
# ----------------------------------------------------------------- private ---
# The tick does not do the work, it schedules it. git, chezmoi and bws have to
# exist before either row can act, and a password typed at picker time would sit
# in memory for the ten minutes of package downloads in between. So it happens
# here, immediately before it is used, and never at all without a human.
PRIV_SRC=${DOTUP_PRIVATE_SRC:-${XDG_DATA_HOME:-$HOME/.local/share}/dotfiles-private}
BWS_TOKEN=${DOTUP_BWS_TOKEN:-${XDG_CONFIG_HOME:-$HOME/.config}/bitwarden/bws-token}
cmd_private() {
rows=$(selected_private)
[ -n "$rows" ] || return 0
# The §1 boundary is structural: it holds because there is nobody to type a
# password, not because of a policy this is obeying.
if [ "$UNATTENDED" -eq 1 ]; then
warn "unattended: the private tier needs a password nobody is here to type — skipped"
return 0
fi
[ "$DRYRUN" -eq 0 ] || { head_ "private"; printf ' + prompt for URL, username, password (interactive only)\n'; return 0; }
[ -t 0 ] || { warn "no terminal: the private tier needs a password — skipped"; return 0; }
want_repo=0; want_bws=0
case $rows in *private/private-repo*) want_repo=1 ;; esac
case $rows in *private/bws-secrets*) want_bws=1 ;; esac
# A satisfied row is silent. You only see the prompt for something absent.
[ ! -d "$PRIV_SRC" ] || { say " private repo already present at $PRIV_SRC"; want_repo=0; }
[ ! -r "$BWS_TOKEN" ] || { say " bws token already present"; want_bws=0; }
[ "$want_repo" -eq 1 ] || [ "$want_bws" -eq 1 ] || return 0
head_ "private tier"
say " The address is in no repository. Leave it blank to stay public-only."
# Read into variables: nothing reaches argv, so nothing reaches `ps`.
P_URL=''; P_USER=''; P_PW=''
printf ' Bootstrap URL: ' >&2; IFS= read -r P_URL || :
[ -n "$P_URL" ] || { say " public-only machine. Nothing was asked for."; return 0; }
printf ' Username: ' >&2; IFS= read -r P_USER || :
printf ' Password: ' >&2
stty -echo 2>/dev/null || :; IFS= read -r P_PW || :; stty echo 2>/dev/null || :; printf '\n' >&2
# shellcheck disable=SC2064
trap 'unset P_URL P_USER P_PW P_BLOB 2>/dev/null || :' EXIT INT TERM
# curl -K - reads its config, credentials included, from stdin rather than
# the command line. --fail matters too: without it a 401 body is parsed as
# if it were the blob.
P_BLOB=$(printf 'user = "%s:%s"\nsilent\nfail\n' "$P_USER" "$P_PW" \
| curl -K - "${P_URL%/}/bootstrap.env" 2>/dev/null) || {
err "endpoint refused the credentials. The machine stays public-only."
unset P_PW; return 1; }
unset P_PW # spent. only the fetched credentials exist now.
# Contract with the endpoint (phase 4 writes the file this parses):
# two KEY=VALUE lines, no quoting, no shell
# PRIVATE_REPO_URL=https://<user>:<read-only-token>@host/path/dotfiles-private.git
# BWS_ACCESS_TOKEN=<machine account token, scoped to one project>
p_repo=$(printf '%s\n' "$P_BLOB" | sed -n 's/^PRIVATE_REPO_URL=//p' | head -1)
p_tok=$(printf '%s\n' "$P_BLOB" | sed -n 's/^BWS_ACCESS_TOKEN=//p' | head -1)
unset P_BLOB
if [ "$want_bws" -eq 1 ]; then
if [ -n "$p_tok" ]; then
mkdir -p "$(dirname "$BWS_TOKEN")"
( umask 077; printf '%s\n' "$p_tok" > "$BWS_TOKEN" )
chmod 600 "$BWS_TOKEN"
say " bws token written, mode 600"
else err "the blob carried no BWS_ACCESS_TOKEN"; fi
fi
unset p_tok
if [ "$want_repo" -eq 1 ]; then
if [ -n "$p_repo" ]; then
run chezmoi init --apply --source "$PRIV_SRC" "$p_repo" \
|| err "private repo init failed"
else err "the blob carried no PRIVATE_REPO_URL"; fi
fi
unset p_repo
trap - EXIT INT TERM
}
# --------------------------------------------------------------- preflight ---
# fzf cannot come from the manifest: the picker needs it to draw the list that
# installs it. Same bootstrap problem chezmoi has. So it is fetched here, before
# any UI exists.
#
# FLOOR is exactly where verification stops, not a guess. Every release from
# 0.29 up parses every option and binding the picker uses, but cursor-on-reload
# — the property that makes ticking a list bearable — can only be measured from
# 0.44.1, where fzf's --listen API began reporting state. Below that it is
# unverifiable rather than known-broken, so we decline to rely on it.
#
# The binary is dotup's own, not yours. It lands in a cache directory and is
# invoked by absolute path — PATH is never touched. Dropping a newer fzf into
# ~/.local/bin would shadow the distro's copy for Ctrl-R, the oh-my-zsh plugin
# and every other script, which is precisely the kind of silent change the
# `invasive` flag exists to refuse. Not the repo either: a vendored binary means
# either megabytes in git or a .gitignore rule, and the public repo stays clean.
FZF_FLOOR=0.44.0
FZF_PIN=0.74.2
FZF_CACHE=${XDG_CACHE_HOME:-$HOME/.cache}/dotup
FZF=
# $1 >= $2, dotted numeric. Not `sort -V`: BSD sort on older macOS lacks it.
ver_ge() {
awk -v a="$1" -v b="$2" 'BEGIN{
na=split(a,A,"."); nb=split(b,B,".")
for(i=1;i<=3;i++){ x=(i<=na)?A[i]+0:0; y=(i<=nb)?B[i]+0:0
if(x>y) exit 0; if(x<y) exit 1 }
exit 0 }'
}
fzf_version() { fzf --version 2>/dev/null | awk '{print $1}'; }
install_fzf() {
case $(uname -s) in Darwin) os=darwin ;; *) os=linux ;; esac
case $(uname -m) in
x86_64|amd64) arch=amd64 ;;
aarch64|arm64) arch=arm64 ;;
armv7l) arch=armv7 ;;
*) echo "dotup: unsupported arch $(uname -m); install fzf yourself" >&2; return 1 ;;
esac
# The release tag gained a leading v between 0.53.0 and 0.55.0. Anything we
# would pin today is above that; the fallback keeps an older pin working.
base=https://github.com/junegunn/fzf/releases/download
mkdir -p "$FZF_CACHE"
tmp=$FZF_CACHE/.fzf.$$
echo "dotup: fetching fzf $FZF_PIN ($os/$arch) for its own use" >&2
if curl -sfL "$base/v$FZF_PIN/fzf-$FZF_PIN-${os}_${arch}.tar.gz" | tar xz -O fzf > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then :
elif curl -sfL "$base/$FZF_PIN/fzf-$FZF_PIN-${os}_${arch}.tar.gz" | tar xz -O fzf > "$tmp" 2>/dev/null && [ -s "$tmp" ]; then :
else rm -f "$tmp"; echo "dotup: could not fetch fzf $FZF_PIN" >&2; return 1; fi
chmod +x "$tmp" && mv "$tmp" "$FZF_CACHE/fzf"
}
# Resolution order, cheapest first. The machine's own fzf wins when it clears
# the floor — nothing is replaced merely for being old.
ensure_fzf() {
if [ -x "$FZF_CACHE/fzf" ] && ver_ge "$("$FZF_CACHE/fzf" --version 2>/dev/null | awk '{print $1}')" "$FZF_FLOOR"; then
FZF=$FZF_CACHE/fzf; return 0
fi
if command -v fzf >/dev/null 2>&1; then
cur=$(fzf_version)
if [ -n "$cur" ] && ver_ge "$cur" "$FZF_FLOOR"; then
FZF=$(command -v fzf); return 0
fi
echo "dotup: system fzf $cur is below the verified floor $FZF_FLOOR" >&2
fi
install_fzf || return 1
FZF=$FZF_CACHE/fzf
}
# ------------------------------------------------------------------- pick ----
cmd_pick() {
ensure_fzf || { echo "dotup: no usable fzf; use the numbered prompt" >&2; return 2; }
[ -s "$SEL" ] || cmd_preset defaults
# --exact is a safety property, not a preference. ^t toggles every row the
# filter is showing, so the filter must mean exactly what it looks like.
# Fuzzy-matching "nvidia" also matches docker, tailscale and desktop.
cmd_render | "$FZF" --ansi --exact --no-sort --cycle --multi --layout=reverse --height=100% \
--delimiter='\t' --with-nth=1 --pointer='>' --marker=' ' \
--info=inline --border=none \
--header=$'space tick tab open ^t tick all shown ^a defaults ^x none ^o open all enter install\n' \
--preview "$SELF explain {2}" --preview-window='right,46%,wrap,border-left' \
--bind "space:execute-silent($SELF toggle {2})+reload($SELF render)" \
--bind "tab:execute-silent($SELF expand {2})+reload($SELF render)" \
--bind "ctrl-t:select-all+execute-silent($SELF toggle {+2})+clear-selection+reload($SELF render)" \
--bind "ctrl-a:execute-silent($SELF preset defaults)+reload($SELF render)" \
--bind "ctrl-x:execute-silent($SELF preset none)+reload($SELF render)" \
--bind "ctrl-o:execute-silent($SELF expand-all)+reload($SELF render)" \
--bind 'enter:accept' > /dev/null || return 1
}
confirm() {
[ "$ASSUME_YES" -eq 0 ] || return 0
[ -t 0 ] || return 0
printf '\n install? [y/N] ' >&2
a=''; IFS= read -r a || a=''
case $a in y|Y|yes|YES) return 0 ;; *) say " nothing installed."; return 1 ;; esac
}
cmd_run() {
if [ "$UNATTENDED" -eq 1 ]; then
# Deterministic by construction: computed fresh from the manifest, never
# inherited from whatever a previous interactive run left in the state
# file. "What a VM or CI run gets" has to mean the same thing twice.
cmd_preset defaults
cmd_plan
else
cmd_pick || return $?
cmd_plan
confirm || return 0
fi
rc=0
cmd_install || rc=$?
cmd_private || :
return $rc
}
# ------------------------------------------------------------------ usage ----
usage() {
cat >&2 <<-EOF
usage: dotup [--unattended] [--print] [--yes]
(no flags) the picker, then install what you ticked
--unattended no UI: safe defaults, never prompts, never private
--print, -n resolve everything and print the commands, install nothing
--yes, -y skip the confirmation after the picker
plumbing, called by the fzf bindings:
render toggle expand expand-all preset explain plan preflight
EOF
}
CMD=
while [ $# -gt 0 ]; do
case $1 in
--unattended) UNATTENDED=1; ASSUME_YES=1 ;;
--print|-n) DRYRUN=1 ;;
--yes|-y) ASSUME_YES=1 ;;
-h|--help) usage; exit 0 ;;
--*) err "unknown flag: $1"; usage; exit 2 ;;
*) CMD=$1; shift; break ;;
esac
shift
done
case ${CMD:-run} in
run) cmd_run ;;
pick) cmd_pick && cmd_plan ;;
install) cmd_install ;;
private) cmd_private ;;
render) cmd_render ;;
toggle) cmd_toggle "$@" ;;
expand) cmd_expand "$@" ;;
expand-all) cmd_expand_all ;;
preset) cmd_preset "${1:-defaults}" ;;
explain) cmd_explain "${1:-}" ;;
plan) cmd_plan ;;
resolve) resolve "${1:-}" ;; # test hook
preflight) ensure_fzf && echo "using $FZF ($("$FZF" --version | awk '{print $1}'), floor $FZF_FLOOR)" ;;
fzf-path) ensure_fzf >/dev/null 2>&1 && echo "$FZF" ;; # test hook
vercmp) ver_ge "$1" "$2" ;; # test hook
*) usage; exit 2 ;;
esac
+167
View File
@@ -0,0 +1,167 @@
#!/usr/bin/env bash
# sysjournal — shared system-knowledge journal for the Obsidian vault.
#
# One tool, used by every coding agent (Claude Code, Codex, Pi), so host/
# environment changes get recorded in ONE consistent, greppable place with
# the frontmatter schema that "Tech/Infrastructure/System Log MOC.md" indexes
# via Dataview (date, machine, type, status, tags, review-by).
#
# Journal HOST/ENVIRONMENT changes — system config, services, networking,
# VMs, drivers, build toolchains, host-wiring of an app. NOT ordinary work
# inside a code repo. See `sysjournal help`.
#
# Portable bash (invoked by agents in varied environments), not zsh.
set -euo pipefail
VAULT="${SYSJOURNAL_VAULT:-$HOME/Documents/Obsidian25}"
SUBDIR="${SYSJOURNAL_SUBDIR:-Tech/Infrastructure}"
INFRA="$VAULT/$SUBDIR"
die() { printf 'sysjournal: %s\n' "$*" >&2; exit 1; }
ensure_dir() {
[ -d "$VAULT" ] || die "vault not found: $VAULT (set SYSJOURNAL_VAULT)"
mkdir -p "$INFRA"
}
usage() {
cat <<'EOF'
sysjournal — record & recall host/environment changes in the Obsidian vault.
USAGE
sysjournal search <keywords...> Recall: grep the journal first (case-insensitive)
sysjournal new "<Title>" [options] Scaffold a new note, print its path
sysjournal list [N] List the N most-recent notes (default 20)
sysjournal path Print the infrastructure folder path
sysjournal help This help
`new` OPTIONS
--type <t> change | setup | debug | fix | incident | note (default: change)
--status <s> deployed | resolved | unresolved | workaround | planned (default: deployed)
--tags a,b,c comma-separated tags
--machine <m> host the change was made on (default: `hostname -s`)
--review-by <YYYY-MM-DD> optional follow-up/expiry date
--summary "<one line>" optional lead line
WHAT TO JOURNAL
YES system config, services/daemons (systemd/launchd/cron), networking/DNS/
VPN/firewall, VMs & host containers, drivers/kernel/boot, disks/mounts,
build toolchains & global package installs, wiring an app into the host.
NO feature work, bug fixes, refactors, tests INSIDE a project repo.
Discriminator: does it change state outside the repo, on the host? If no, skip.
Straddle (build an app AND install it as a service): journal only the
host-wiring part (the unit/cron/port), not the app code.
EXAMPLES
sysjournal search systemd relay port
sysjournal new "WireGuard VPN to homelab" --type setup --tags wireguard,vpn,network
sysjournal new "DNS resolution flaky after netplan change" --type debug --status unresolved
EOF
}
cmd_search() {
ensure_dir
[ "$#" -ge 1 ] || die "search needs at least one keyword"
# OR-match the keywords so a few loosely-related terms still surface notes.
local pattern
pattern=$(printf '%s|' "$@"); pattern="${pattern%|}"
echo "# Journal matches in $SUBDIR for: $*"
echo
if ! rg -i --no-heading -n -C1 --color never -e "$pattern" "$INFRA" 2>/dev/null; then
echo "(no matches — nothing journaled on this yet)"
fi
}
cmd_list() {
ensure_dir
local n="${1:-20}"
# Newest first by mtime; strip the vault prefix for readability.
find "$INFRA" -maxdepth 1 -name '*.md' -printf '%T@ %p\n' 2>/dev/null \
| sort -rn | head -n "$n" | sed "s#[0-9.]* $INFRA/##"
}
cmd_path() { echo "$INFRA"; }
cmd_new() {
ensure_dir
local title="" type="change" status="deployed" tags="" machine review_by="" summary=""
machine="$(hostname -s 2>/dev/null || hostname)"
# First non-flag arg is the title.
while [ "$#" -gt 0 ]; do
case "$1" in
--type) type="${2:?--type needs a value}"; shift 2;;
--status) status="${2:?--status needs a value}"; shift 2;;
--tags) tags="${2:?--tags needs a value}"; shift 2;;
--machine) machine="${2:?--machine needs a value}"; shift 2;;
--review-by) review_by="${2:?--review-by needs a value}"; shift 2;;
--summary) summary="${2:?--summary needs a value}"; shift 2;;
--*) die "unknown option: $1";;
*) [ -z "$title" ] && title="$1" || die "unexpected arg: $1"; shift;;
esac
done
[ -n "$title" ] || die 'new needs a "<Title>"'
# Filename: keep the human title (Obsidian-friendly), drop only path-illegal chars.
local fname; fname=$(printf '%s' "$title" | tr '/\\' '--' | sed 's/[[:cntrl:]]//g; s/ */ /g; s/^ *//; s/ *$//')
local file="$INFRA/$fname.md"
if [ -e "$file" ]; then
echo "$file" # already exists — recall, don't clobber; edit/append this note.
echo "sysjournal: note already exists — edit it instead of creating a duplicate." >&2
return 0
fi
# YAML frontmatter — inline tag list, matching the MOC's own `tags: [moc]` style.
local yaml_tags="[]"
if [ -n "$tags" ]; then
yaml_tags="[$(printf '%s' "$tags" | sed 's/ *, */, /g')]"
fi
local date_today; date_today="$(date +%F)"
{
echo "---"
echo "date: $date_today"
echo "machine: $machine"
echo "type: $type"
echo "status: $status"
echo "tags: $yaml_tags"
[ -n "$review_by" ] && echo "review-by: $review_by"
echo "---"
echo
echo "# $title"
echo
[ -n "$summary" ] && { echo "$summary"; echo; }
echo "## Why"
echo
echo "## What changed"
echo
echo "## Design decisions / gotchas"
echo
echo "## Verify"
echo
echo '```'
echo '# command run + observed result'
echo '```'
echo
echo "## Status / follow-ups"
echo
echo "Related: "
} > "$file"
echo "$file"
}
main() {
local sub="${1:-help}"; shift || true
case "$sub" in
search|recall|grep) cmd_search "$@";;
new|add) cmd_new "$@";;
list|ls) cmd_list "$@";;
path|dir) cmd_path;;
help|-h|--help) usage;;
*) usage; die "unknown command: $sub";;
esac
}
main "$@"
+119
View File
@@ -0,0 +1,119 @@
#group pkg flag apt brew note
#
# Five columns, tab separated, greppable by hand. Anything that would have been
# a sixth column is an @ directive instead, so a package with no dependency and
# no install argument -- most of them -- costs nothing to read.
#
# @needs <group/pkg> <dep>... dep is group/pkg or a whole group.
# Closure is transitive in both directions.
# @spec <group/pkg> <arg>... Argument for a channel that cannot be named
# by the package: the npm spec, the flatpak
# app id, the .deb source. Defaults to the
# package name when absent.
# @<group> <note> Group note. Only needed where the members
# fail the safe test for different reasons;
# otherwise the worst child's note is right.
#
# A leading `-` in the apt or brew column means "not from this package manager":
# -tarball -npm -script -snap -deb -flatpak -uv -builtin -xcode.
# A bare `-` means unavailable there. Linux falls back to the brew column before
# giving up -- omp, herdr and lazygit have no apt package at all. The fallback is
# one-directional: there is no apt on a Mac, so a `-` in the brew column is the
# end of the road rather than a reason to read a column of Debian package names.
#
@needs networking/xrdp desktop
@needs networking/xorgxrdp desktop
@needs gpu/container-toolkit docker
@needs agents/codex core/node
@needs agents/pi core/node
@needs agents/pi-plugins core/node
@needs agents/pi-plugins agents/pi
@needs agents/specify-cli core/uv
@needs core/mermaid-cli core/node
@needs core/neovim core/imagemagick core/mermaid-cli
#
# npm names carry scopes that the plugin's short name does not. `npm i -g
# rpiv-btw` installs somebody else's package.
@spec agents/codex @openai/codex
@spec agents/pi @earendil-works/pi-coding-agent
@spec agents/pi-plugins @juicesharp/rpiv-ask-user-question @juicesharp/rpiv-btw @juicesharp/rpiv-todo @samfp/pi-memory @tmustier/pi-ralph-wiggum pi-markdown-preview pi-powerline-footer pi-simplify pi-subagents pi-web-access
@spec agents/specify-cli specify-cli
@spec core/mermaid-cli @mermaid-js/mermaid-cli
@spec core/bitwarden-cli bw
@spec apps/obsidian md.obsidian.Obsidian
@spec apps/chrome https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb
@spec apps/ghostty gh:mkasberg/ghostty-ubuntu:_amd64.deb
core neovim safe -tarball neovim apt ships 0.9.5 — tarball to /opt/nvim
core ripgrep safe ripgrep ripgrep binary is rg
core fd safe fd-find fd binary is fdfind on ubuntu
core bat safe bat bat binary is batcat on ubuntu
core fzf safe fzf fzf for your shell (ctrl-r); the picker uses its own pinned copy
core eza safe eza eza
core zsh safe zsh -builtin login shell everywhere
core tmux safe tmux tmux
core git-lfs safe git-lfs git-lfs
core lazygit safe - lazygit no apt package
core gh safe gh gh
core chezmoi safe -script chezmoi bootstrap cannot come from the manifest
core go safe -tarball go /usr/local/go on linux
core uv safe -script uv astral installer to ~/.local/bin
core node safe nodejs npm node apt's nodejs ships node WITHOUT npm — both names needed
core imagemagick safe imagemagick imagemagick required by the nvim markdown render path
core mermaid-cli safe -npm -npm mmdc — nvim renders mermaid fences with it
core btop safe btop btop
core htop safe htop htop
core ncdu safe ncdu ncdu
core tree safe tree tree
core cmake safe cmake cmake
core ninja safe ninja-build ninja package name differs from binary
core build-tools safe build-essential -xcode
core magic-wormhole safe magic-wormhole magic-wormhole snap wormhole on dev
core bitwarden-cli safe -snap bitwarden-cli snap bw on ubuntu
core mosh safe mosh mosh
core nmap safe nmap nmap
core binwalk safe binwalk binwalk
core pipx safe pipx pipx
core age safe age age general purpose only now
agents codex safe -npm -npm @openai/codex — unpinned, always latest
agents pi safe -npm -npm @earendil-works/pi-coding-agent
agents pi-plugins safe -npm -npm 10 plugins: rpiv-*, pi-memory, pi-subagents, ...
agents omp safe - can1357/tap/omp oh my pi — tap only, pulls linuxbrew on linux
agents herdr safe - herdr terminal workspace manager for agents
agents specify-cli safe -uv -uv uv tool install
fonts hack-nerd-font safe fonts-powerline font-hack-nerd-font
fonts iosevka-nerd-font safe fonts-powerline font-iosevka-nerd-font
media ffmpeg safe ffmpeg ffmpeg
media sox safe sox sox
media p7zip safe p7zip-full p7zip
apps obsidian gui -flatpak obsidian
apps ghostty gui -deb ghostty
apps chrome gui -deb google-chrome
apps firefox gui firefox firefox
apps vlc gui vlc vlc
@networking daemons, listening ports, and setuid mount helpers
networking openssh-server invasive openssh-server -builtin opens port 22 on every network this box can reach
networking tailscale invasive tailscale tailscale daemon; joins a private network and rewrites DNS
networking avahi-daemon invasive avahi-daemon - daemon; broadcasts this host on the LAN
networking xrdp invasive xrdp - opens port 3389 · useless without the desktop group
networking xorgxrdp invasive xorgxrdp - xrdp's X backend
networking nfs-common invasive nfs-common - setuid mount helper
networking cifs-utils invasive cifs-utils - setuid mount helper
networking davfs2 invasive davfs2 - setuid mount helper
@docker daemon; membership in the docker group is root-equivalent
docker docker-ce invasive docker-ce - daemon; docker group is root-equivalent
docker docker-buildx invasive docker-buildx-plugin -
docker docker-compose invasive docker-compose-plugin -
@desktop changes the display manager — can leave you at a black screen
desktop xfce4 invasive xfce4 - changes the display manager
desktop lightdm invasive lightdm - CAN LEAVE YOU AT A BLACK SCREEN
@gpu kernel modules; a bad driver can break boot
gpu nvidia-driver invasive nvidia-driver-570 - kernel modules; can break boot
gpu cuda-toolkit invasive nvidia-cuda-toolkit -
gpu container-toolkit invasive nvidia-container-toolkit - requires docker
@virt daemon, bridges, and group membership
virt qemu invasive qemu-kvm -
virt libvirt invasive libvirt-daemon-system - daemon + group membership
virt virt-manager invasive virt-manager -
@private one password, typed after the install finishes
private private-repo private - - ~/.local/share/dotfiles-private — agent config, ssh config
private bws-secrets private - - 7 API keys into ~/.config/zsh/secrets.zsh
Can't render this file because it contains an unexpected character in line 17 and column 49.
+531
View File
@@ -0,0 +1,531 @@
# --- clipboard: allow OSC 52 passthrough from nvim ---
set -g allow-passthrough on #!important
set -s set-clipboard on #!important
# --- claude-managed: session persistence ---
set -g @continuum-restore 'on'
set -g @resurrect-capture-pane-contents 'on'
run-shell ~/.tmux/plugins/tmux-resurrect/resurrect.tmux
run-shell ~/.tmux/plugins/tmux-continuum/continuum.tmux
# : << 'EOF'
# Oh my tmux!
# 💛🩷💙🖤❤️🤍
# https://github.com/gpakosz/.tmux
# (‑●‑●)> dual licensed under the WTFPL v2 license and the MIT license,
# without any warranty.
# Copyright 2012— Gregory Pakosz (@gpakosz).
# -- bindings ------------------------------------------------------------------
# preserve tmux stock bindings,
# while adding bindings that don't conflict with these stock bindings
# /!\ this disables some of Oh my tmux! bindings described in README.md
# - true
# - false (default)
tmux_conf_preserve_stock_bindings=false
# -- session creation ----------------------------------------------------------
# prompt for session name when creating a new session, possible values are:
# - true
# - false (default)
# - disabled (do not modify new-session bindings)
tmux_conf_new_session_prompt=false
# new session retains current path, possible values are:
# - true
# - false (default)
# - disabled (do not modify new-session bindings)
tmux_conf_new_session_retain_current_path=false
# -- windows & pane creation ---------------------------------------------------
# new window retains current path, possible values are:
# - true
# - false (default)
# - disabled (do not modify new-window bindings)
tmux_conf_new_window_retain_current_path=false
# new window tries to reconnect ssh sessions, possible values are:
# - true
# - false (default)
# - disabled (do not modify new-window bindings)
tmux_conf_new_window_reconnect_ssh=false
# new pane retains current path, possible values are:
# - true (default)
# - false
# - disabled (do not modify split-window bindings)
tmux_conf_new_pane_retain_current_path=true
# new pane tries to reconnect ssh sessions, possible values are:
# - true
# - false (default)
# - disabled (do not modify split-window bindings)
tmux_conf_new_pane_reconnect_ssh=false
# -- display -------------------------------------------------------------------
# RGB 24-bit colour support, possible values are:
# - true
# - false
# - auto (default)
#
# automatic detection relies on the COLORTERM environment variable being defined
# to 'truecolor' or '24bit' or '$ tput colors' answering '16777216'
# see https://github.com/termstandard/colors
tmux_conf_24b_colour=auto
# -- theming -------------------------------------------------------------------
# enable or disable theming:
# - enabled (default)
# - disabled
# when disabled, all tmux_conf_theme_xxx variables are ignored except:
# - tmux_conf_theme_pairing
# - tmux_conf_theme_prefix
# - tmux_conf_theme_mouse
# - tmux_conf_theme_root
# - tmux_conf_theme_synchronized
tmux_conf_theme=enabled
# default theme
tmux_conf_theme_colour_1="#080808" # dark gray
tmux_conf_theme_colour_2="#303030" # gray
tmux_conf_theme_colour_3="#8a8a8a" # light gray
tmux_conf_theme_colour_4="#00afff" # light blue
tmux_conf_theme_colour_5="#ffff00" # yellow
tmux_conf_theme_colour_6="#080808" # dark gray
tmux_conf_theme_colour_7="#e4e4e4" # white
tmux_conf_theme_colour_8="#080808" # dark gray
tmux_conf_theme_colour_9="#ffff00" # yellow
tmux_conf_theme_colour_10="#ff00af" # pink
tmux_conf_theme_colour_11="#5fff00" # green
tmux_conf_theme_colour_12="#8a8a8a" # light gray
tmux_conf_theme_colour_13="#e4e4e4" # white
tmux_conf_theme_colour_14="#080808" # dark gray
tmux_conf_theme_colour_15="#080808" # dark gray
tmux_conf_theme_colour_16="#d70000" # red
tmux_conf_theme_colour_17="#e4e4e4" # white
# default theme (ansi)
#tmux_conf_theme_colour_1="colour0"
#tmux_conf_theme_colour_2="colour8"
#tmux_conf_theme_colour_3="colour8"
#tmux_conf_theme_colour_4="colour14"
#tmux_conf_theme_colour_5="colour11"
#tmux_conf_theme_colour_6="colour0"
#tmux_conf_theme_colour_7="colour15"
#tmux_conf_theme_colour_8="colour0"
#tmux_conf_theme_colour_9="colour11"
#tmux_conf_theme_colour_10="colour13"
#tmux_conf_theme_colour_11="colour10"
#tmux_conf_theme_colour_12="colour8"
#tmux_conf_theme_colour_13="colour15"
#tmux_conf_theme_colour_14="colour0"
#tmux_conf_theme_colour_15="colour0"
#tmux_conf_theme_colour_16="colour1"
#tmux_conf_theme_colour_17="colour15"
# window style
tmux_conf_theme_window_fg="default"
tmux_conf_theme_window_bg="default"
# highlight focused pane, possible values are:
# - true
# - false (default)
tmux_conf_theme_highlight_focused_pane=false
# focused pane colours:
tmux_conf_theme_focused_pane_bg="$tmux_conf_theme_colour_2"
# pane border style, possible values are:
# - thin (default)
# - fat
tmux_conf_theme_pane_border_style=thin
# pane borders colours:
tmux_conf_theme_pane_border="$tmux_conf_theme_colour_2"
tmux_conf_theme_pane_active_border="$tmux_conf_theme_colour_4"
%if #{>=:#{version},3.2}
tmux_conf_theme_pane_active_border="#{?pane_in_mode,$tmux_conf_theme_colour_9,#{?synchronize-panes,$tmux_conf_theme_colour_16,$tmux_conf_theme_colour_4}}"
%endif
# pane indicator colours (when you hit <prefix> + q)
tmux_conf_theme_pane_indicator="$tmux_conf_theme_colour_4"
tmux_conf_theme_pane_active_indicator="$tmux_conf_theme_colour_4"
# status line style
tmux_conf_theme_message_fg="$tmux_conf_theme_colour_1"
tmux_conf_theme_message_bg="$tmux_conf_theme_colour_5"
tmux_conf_theme_message_attr="bold"
# status line command style (<prefix> : Escape)
tmux_conf_theme_message_command_fg="$tmux_conf_theme_colour_5"
tmux_conf_theme_message_command_bg="$tmux_conf_theme_colour_1"
tmux_conf_theme_message_command_attr="bold"
# window modes style
tmux_conf_theme_mode_fg="$tmux_conf_theme_colour_1"
tmux_conf_theme_mode_bg="$tmux_conf_theme_colour_5"
tmux_conf_theme_mode_attr="bold"
# status line style
tmux_conf_theme_status_fg="$tmux_conf_theme_colour_3"
tmux_conf_theme_status_bg="$tmux_conf_theme_colour_1"
tmux_conf_theme_status_attr="none"
# terminal title
# - built-in variables are:
# - #{circled_window_index}
# - #{circled_session_name}
# - #{hostname}
# - #{hostname_ssh}
# - #{hostname_full}
# - #{hostname_full_ssh}
# - #{username}
# - #{username_ssh}
tmux_conf_theme_terminal_title="#h ❐ #S ● #I #W"
# window status style
# - built-in variables are:
# - #{circled_window_index}
# - #{circled_session_name}
# - #{hostname}
# - #{hostname_ssh}
# - #{hostname_full}
# - #{hostname_full_ssh}
# - #{username}
# - #{username_ssh}
tmux_conf_theme_window_status_fg="$tmux_conf_theme_colour_3"
tmux_conf_theme_window_status_bg="$tmux_conf_theme_colour_1"
tmux_conf_theme_window_status_attr="none"
tmux_conf_theme_window_status_format="#I #W#{?#{||:#{window_bell_flag},#{window_zoomed_flag}}, ,}#{?window_bell_flag,!,}#{?window_zoomed_flag,Z,}"
#tmux_conf_theme_window_status_format="#{circled_window_index} #W#{?#{||:#{window_bell_flag},#{window_zoomed_flag}}, ,}#{?window_bell_flag,!,}#{?window_zoomed_flag,Z,}"
#tmux_conf_theme_window_status_format="#I #W#{?#{||:#{window_bell_flag},#{window_zoomed_flag}}, ,}#{?window_bell_flag,🔔,}#{?window_zoomed_flag,🔍,}"
# window current status style
# - built-in variables are:
# - #{circled_window_index}
# - #{circled_session_name}
# - #{hostname}
# - #{hostname_ssh}
# - #{hostname_full}
# - #{hostname_full_ssh}
# - #{username}
# - #{username_ssh}
tmux_conf_theme_window_status_current_fg="$tmux_conf_theme_colour_1"
tmux_conf_theme_window_status_current_bg="$tmux_conf_theme_colour_4"
tmux_conf_theme_window_status_current_attr="bold"
tmux_conf_theme_window_status_current_format="#I #W#{?#{||:#{window_bell_flag},#{window_zoomed_flag}}, ,}#{?window_bell_flag,!,}#{?window_zoomed_flag,Z,}"
#tmux_conf_theme_window_status_current_format="#{circled_window_index} #W#{?#{||:#{window_bell_flag},#{window_zoomed_flag}}, ,}#{?window_bell_flag,!,}#{?window_zoomed_flag,Z,}"
#tmux_conf_theme_window_status_current_format="#I #W#{?#{||:#{window_bell_flag},#{window_zoomed_flag}}, ,}#{?window_bell_flag,🔔,}#{?window_zoomed_flag,🔍,}"
# window activity status style
tmux_conf_theme_window_status_activity_fg="default"
tmux_conf_theme_window_status_activity_bg="default"
tmux_conf_theme_window_status_activity_attr="underscore"
# window bell status style
tmux_conf_theme_window_status_bell_fg="$tmux_conf_theme_colour_5"
tmux_conf_theme_window_status_bell_bg="default"
tmux_conf_theme_window_status_bell_attr="blink,bold"
# window last status style
tmux_conf_theme_window_status_last_fg="$tmux_conf_theme_colour_4"
tmux_conf_theme_window_status_last_bg="$tmux_conf_theme_colour_2"
tmux_conf_theme_window_status_last_attr="none"
# status left/right sections separators
#tmux_conf_theme_left_separator_main=""
#tmux_conf_theme_left_separator_sub="|"
#tmux_conf_theme_right_separator_main=""
#tmux_conf_theme_right_separator_sub="|"
tmux_conf_theme_left_separator_main='\uE0B0' # /!\ you don't need to install Powerline
tmux_conf_theme_left_separator_sub='\uE0B1' # you only need fonts patched with
tmux_conf_theme_right_separator_main='\uE0B2' # Powerline symbols or the standalone
tmux_conf_theme_right_separator_sub='\uE0B3' # PowerlineSymbols.otf font, see README.md
# status left/right content:
# - separate main sections with "|"
# - separate subsections with ","
# - built-in variables are:
# - #{battery_bar}
# - #{battery_hbar}
# - #{battery_percentage}
# - #{battery_status}
# - #{battery_vbar}
# - #{circled_session_name}
# - #{hostname_ssh}
# - #{hostname}
# - #{hostname_full}
# - #{hostname_full_ssh}
# - #{loadavg}
# - #{mouse}
# - #{pairing}
# - #{prefix}
# - #{root}
# - #{synchronized}
# - #{uptime_y}
# - #{uptime_d} (modulo 365 when #{uptime_y} is used)
# - #{uptime_h}
# - #{uptime_m}
# - #{uptime_s}
# - #{username}
# - #{username_ssh}
tmux_conf_theme_status_left=" ❐ #S | ↑#{?uptime_y, #{uptime_y}y,}#{?uptime_d, #{uptime_d}d,}#{?uptime_h, #{uptime_h}h,}#{?uptime_m, #{uptime_m}m,} "
#tmux_conf_theme_status_left=" ❐ #S | ↑#{?uptime_y, #{uptime_y}y,}#{?uptime_d, #{uptime_d}d,}#{?uptime_h, #{uptime_h}h,}#{?uptime_m, #{uptime_m}m,} | #{pretty_pane_current_path} "
tmux_conf_theme_status_right=" #{prefix}#{mouse}#{pairing}#{synchronized}#{?battery_status,#{battery_status},}#{?battery_bar, #{battery_bar},}#{?battery_percentage, #{battery_percentage},} , %R , %d %b | #{username}#{root} | #{hostname} "
# status left style
tmux_conf_theme_status_left_fg="$tmux_conf_theme_colour_6,$tmux_conf_theme_colour_7,$tmux_conf_theme_colour_8"
tmux_conf_theme_status_left_bg="$tmux_conf_theme_colour_9,$tmux_conf_theme_colour_10,$tmux_conf_theme_colour_11"
tmux_conf_theme_status_left_attr="bold,none,none"
# status right style
tmux_conf_theme_status_right_fg="$tmux_conf_theme_colour_12,$tmux_conf_theme_colour_13,$tmux_conf_theme_colour_14"
tmux_conf_theme_status_right_bg="$tmux_conf_theme_colour_15,$tmux_conf_theme_colour_16,$tmux_conf_theme_colour_17"
tmux_conf_theme_status_right_attr="none,none,bold"
# pairing indicator
tmux_conf_theme_pairing="⚇" # U+2687
tmux_conf_theme_pairing_fg="none"
tmux_conf_theme_pairing_bg="none"
tmux_conf_theme_pairing_attr="none"
# prefix indicator
tmux_conf_theme_prefix="⌨" # U+2328
tmux_conf_theme_prefix_fg="none"
tmux_conf_theme_prefix_bg="none"
tmux_conf_theme_prefix_attr="none"
# mouse indicator
tmux_conf_theme_mouse="↗" # U+2197
tmux_conf_theme_mouse_fg="none"
tmux_conf_theme_mouse_bg="none"
tmux_conf_theme_mouse_attr="none"
# root indicator
tmux_conf_theme_root="!"
tmux_conf_theme_root_fg="none"
tmux_conf_theme_root_bg="none"
tmux_conf_theme_root_attr="bold,blink"
# synchronized indicator
tmux_conf_theme_synchronized="⚏" # U+268F
tmux_conf_theme_synchronized_fg="none"
tmux_conf_theme_synchronized_bg="none"
tmux_conf_theme_synchronized_attr="none"
# battery bar symbols
tmux_conf_battery_bar_symbol_full="◼"
tmux_conf_battery_bar_symbol_empty="◻"
#tmux_conf_battery_bar_symbol_full="♥"
#tmux_conf_battery_bar_symbol_empty="·"
# battery bar length (in number of symbols), possible values are:
# - auto
# - a number, e.g. 5
tmux_conf_battery_bar_length="auto"
# battery bar palette, possible values are:
# - gradient (default)
# - heat
# - "colour_full_fg,colour_empty_fg,colour_bg"
# - gradient(colour_fg_1,colour_fg_2,...,colour_fg_n)
tmux_conf_battery_bar_palette="gradient"
#tmux_conf_battery_bar_palette="#d70000,#e4e4e4,#000000" # red, white, black
#tmux_conf_battery_bar_palette="gradient(#00afff,#47a2ff,#7c91ff,#ac7afb,#d65be2,#e163df,#eb6cdd,#f475db,#ec9ff1,#eac3fe,#efe2ff,#ffffff)"
# battery hbar palette, possible values are:
# - gradient (default)
# - heat
# - "colour_low,colour_half,colour_full"
# - gradient(colour_fg_1,colour_fg_2,...,colour_fg_n)
tmux_conf_battery_hbar_palette="gradient"
#tmux_conf_battery_hbar_palette="#d70000,#ff5f00,#5fff00" # red, orange, green
#tmux_conf_battery_hbar_palette="gradient(#00afff,#47a2ff,#7c91ff,#ac7afb,#d65be2,#e163df,#eb6cdd,#f475db,#ec9ff1,#eac3fe,#efe2ff,#ffffff)"
# battery vbar palette, possible values are:
# - gradient (default)
# - heat
# - "colour_low,colour_half,colour_full"
# - gradient(colour_fg_1,colour_fg_2,...,colour_fg_n)
tmux_conf_battery_vbar_palette="gradient"
#tmux_conf_battery_vbar_palette="#d70000,#ff5f00,#5fff00" # red, orange, green
#tmux_conf_battery_vbar_palette="gradient(#00afff,#47a2ff,#7c91ff,#ac7afb,#d65be2,#e163df,#eb6cdd,#f475db,#ec9ff1,#eac3fe,#efe2ff,#ffffff)"
# symbols used to indicate whether battery is charging or discharging
tmux_conf_battery_status_charging="↑" # U+2191
tmux_conf_battery_status_discharging="↓" # U+2193
#tmux_conf_battery_status_charging="🔌" # U+1F50C
#tmux_conf_battery_status_discharging="🔋" # U+1F50B
# clock style (when you hit <prefix> + t)
# you may want to use %I:%M %p in place of %R in tmux_conf_theme_status_right
tmux_conf_theme_clock_colour="$tmux_conf_theme_colour_4"
tmux_conf_theme_clock_style="24"
# -- clipboard -----------------------------------------------------------------
# in copy mode, copying selection also copies to the OS clipboard
# - true
# - false (default)
# - disabled
# on Linux, this requires xsel, xclip or wl-copy
tmux_conf_copy_to_os_clipboard=false
# -- urlscan -------------------------------------------------------------------
# options passed to urlscan
tmux_conf_urlscan_options="--compact --dedupe"
# -- user customizations -------------------------------------------------------
# this is the place to override or undo settings
# increase history size
#set -g history-limit 10000
# start with mouse mode enabled
set -g mouse on
# force Vi mode
# really you should export VISUAL or EDITOR environment variable, see manual
#set -g status-keys vi
#set -g mode-keys vi
# replace C-b by C-a instead of using both prefixes
# set -gu prefix2
# unbind C-a
# unbind C-b
# set -g prefix C-a
# bind C-a send-prefix
# if you don't want Oh my tmux! to alter a binding or a setting, use #!important
# bind c new-window -c '#{pane_current_path}' #!important
# display a message after toggling mouse support
bind m run "cut -c3- '#{TMUX_CONF}' | sh -s _toggle_mouse" \; display 'mouse #{?#{mouse},on,off}'
# cycle through windows/tabs with Shift+Left/Right keys (no prefix needed)
bind -n S-Left previous-window
bind -n S-Right next-window
# cycle through sessions/workspaces with Shift+Up/Down keys (no prefix needed)
bind -n S-Up switch-client -p
bind -n S-Down switch-client -n
# move status line to top
#set -g status-position top
# cleaner pane borders (VS Code-like)
set -g pane-border-lines single
set -g pane-border-style fg=brightblack
set -g pane-active-border-style fg=blue
# -- tpm -----------------------------------------------------------------------
# while I don't use tpm myself, many people requested official support so here
# is a seamless integration that automatically installs plugins in parallel
# whenever a plugin introduces a variable to be used in 'status-left' or
# 'status-right', you can use it in 'tmux_conf_theme_status_left' and
# 'tmux_conf_theme_status_right' variables.
# by default, launching tmux will update tpm and all plugins
# - true (default)
# - false
tmux_conf_update_plugins_on_launch=true
# by default, reloading the configuration will update tpm and all plugins
# - true (default)
# - false
tmux_conf_update_plugins_on_reload=true
# by default, reloading the configuration will uninstall tpm and plugins when no
# plugins are enabled
# - true (default)
# - false
tmux_conf_uninstall_plugins_on_reload=true
# /!\ the tpm bindings differ slightly from upstream:
# - installing plugins: <prefix> + I
# - uninstalling plugins: <prefix> + Alt + u
# - updating plugins: <prefix> + u
# /!\ do not add set -g @plugin 'tmux-plugins/tpm'
# /!\ do not add run '~/.tmux/plugins/tpm/tpm'
# to enable a plugin, use the 'set -g @plugin' syntax:
# visit https://github.com/tmux-plugins for available plugins
#set -g @plugin 'tmux-plugins/tmux-copycat'
#set -g @plugin 'tmux-plugins/tmux-cpu'
#set -g @plugin 'tmux-plugins/tmux-resurrect'
#set -g @plugin 'tmux-plugins/tmux-continuum'
#set -g @continuum-restore 'on'
# -- custom variables ----------------------------------------------------------
# to define a custom #{foo} variable, define a POSIX shell function between the
# '# EOF' and the '# "$@"' lines. Please note that the opening brace { character
# must be on the same line as the function name otherwise the parse won't detect
# it.
#
# then, use #{foo} in e.g. the 'tmux_conf_theme_status_left' or the
# 'tmux_conf_theme_status_right' variables.
# ------------------------------------------------------------------------------
# # /!\ do not remove the following line
# EOF
#
# # /!\ do not "uncomment" the functions: the leading "# " characters are needed
#
# # usage: #{weather}
# weather() { # see https://github.com/chubin/wttr.in#one-line-output
# curl -f -s -m 2 'wttr.in?format=3' || printf '\n' # /!\ make sure curl is installed
# sleep 900 # sleep for 15 minutes, throttle network requests whatever the value of status-interval
# }
#
# # usage: #{online}
# online() {
# ping -c 1 1.1.1.1 >/dev/null 2>&1 && printf '✔' || printf '✘'
# }
#
# # usage: #{wan_ip_v4}
# wan_ip_v4() {
# curl -f -s -m 2 -4 ifconfig.me
# sleep 300 # sleep for 5 minutes, throttle network requests whatever the value of status-interval
# }
#
# # usage: #{wan_ip_v6}
# wan_ip_v6() {
# curl -f -s -m 2 -6 ifconfig.me
# sleep 300 # sleep for 5 minutes, throttle network requests whatever the value of status-interval
# }
#
# # usage: #{github_stars}, #{github_stars tmux/tmux}, ...
# github_stars() {
# repository=${1##*https://github.com/}
# repository=${repository%% *}
# repository=${repository%%.git}
# url="https://api.github.com/repos/${repository:-gpakosz/.tmux}"
# curl -s "$url" | perl -MJSON::PP=decode_json -CO -0777 -E '$response = decode_json(readline *STDIN); say ($response->{stargazers_count})'
# sleep 300 # sleep for 5 minutes, throttle network requests whatever the value of status-interval
# }
#
# "$@"
# # /!\ do not remove the previous line
# # do not write below this line
+505
View File
@@ -0,0 +1,505 @@
{{- if eq .chezmoi.os "darwin" -}}
#? Config file for btop v. 1.4.5
#* Name of a btop++/bpytop/bashtop formatted ".theme" file, "Default" and "TTY" for builtin themes.
#* Themes should be placed in "../share/btop/themes" relative to binary or "$HOME/.config/btop/themes"
color_theme = "TTY"
#* If the theme set background should be shown, set to False if you want terminal background transparency.
theme_background = True
#* Sets if 24-bit truecolor should be used, will convert 24-bit colors to 256 color (6x6x6 color cube) if false.
truecolor = True
#* Set to true to force tty mode regardless if a real tty has been detected or not.
#* Will force 16-color mode and TTY theme, set all graph symbols to "tty" and swap out other non tty friendly symbols.
force_tty = False
#* Define presets for the layout of the boxes. Preset 0 is always all boxes shown with default settings. Max 9 presets.
#* Format: "box_name:P:G,box_name:P:G" P=(0 or 1) for alternate positions, G=graph symbol to use for box.
#* Use whitespace " " as separator between different presets.
#* Example: "cpu:0:default,mem:0:tty,proc:1:default cpu:0:braille,proc:0:tty"
presets = "cpu:1:default,proc:0:default cpu:0:default,mem:0:default,net:0:default cpu:0:block,net:0:tty"
#* Set to True to enable "h,j,k,l,g,G" keys for directional control in lists.
#* Conflicting keys for h:"help" and k:"kill" is accessible while holding shift.
vim_keys = False
#* Rounded corners on boxes, is ignored if TTY mode is ON.
rounded_corners = True
#* Default symbols to use for graph creation, "braille", "block" or "tty".
#* "braille" offers the highest resolution but might not be included in all fonts.
#* "block" has half the resolution of braille but uses more common characters.
#* "tty" uses only 3 different symbols but will work with most fonts and should work in a real TTY.
#* Note that "tty" only has half the horizontal resolution of the other two, so will show a shorter historical view.
graph_symbol = "braille"
# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty".
graph_symbol_cpu = "default"
# Graph symbol to use for graphs in gpu box, "default", "braille", "block" or "tty".
graph_symbol_gpu = "default"
# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty".
graph_symbol_mem = "default"
# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty".
graph_symbol_net = "default"
# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty".
graph_symbol_proc = "default"
#* Manually set which boxes to show. Available values are "cpu mem net proc" and "gpu0" through "gpu5", separate values with whitespace.
shown_boxes = "cpu mem net proc"
#* Update time in milliseconds, recommended 2000 ms or above for better sample times for graphs.
update_ms = 2000
#* Processes sorting, "pid" "program" "arguments" "threads" "user" "memory" "cpu lazy" "cpu direct",
#* "cpu lazy" sorts top process over time (easier to follow), "cpu direct" updates top process directly.
proc_sorting = "cpu lazy"
#* Reverse sorting order, True or False.
proc_reversed = False
#* Show processes as a tree.
proc_tree = False
#* Use the cpu graph colors in the process list.
proc_colors = True
#* Use a darkening gradient in the process list.
proc_gradient = True
#* If process cpu usage should be of the core it's running on or usage of the total available cpu power.
proc_per_core = True
#* Show process memory as bytes instead of percent.
proc_mem_bytes = True
#* Show cpu graph for each process.
proc_cpu_graphs = True
#* Use /proc/[pid]/smaps for memory information in the process info box (very slow but more accurate)
proc_info_smaps = False
#* Show proc box on left side of screen instead of right.
proc_left = False
#* (Linux) Filter processes tied to the Linux kernel(similar behavior to htop).
proc_filter_kernel = False
#* In tree-view, always accumulate child process resources in the parent process.
proc_aggregate = False
#* Sets the CPU stat shown in upper half of the CPU graph, "total" is always available.
#* Select from a list of detected attributes from the options menu.
cpu_graph_upper = "Auto"
#* Sets the CPU stat shown in lower half of the CPU graph, "total" is always available.
#* Select from a list of detected attributes from the options menu.
cpu_graph_lower = "iowait"
#* If gpu info should be shown in the cpu box. Available values = "Auto", "On" and "Off".
show_gpu_info = "Auto"
#* Toggles if the lower CPU graph should be inverted.
cpu_invert_lower = True
#* Set to True to completely disable the lower CPU graph.
cpu_single_graph = False
#* Show cpu box at bottom of screen instead of top.
cpu_bottom = False
#* Shows the system uptime in the CPU box.
show_uptime = True
#* Shows the CPU package current power consumption in watts. Requires running `make setcap` or `make setuid` or running with sudo.
show_cpu_watts = True
#* Show cpu temperature.
check_temp = True
#* Which sensor to use for cpu temperature, use options menu to select from list of available sensors.
cpu_sensor = "Auto"
#* Show temperatures for cpu cores also if check_temp is True and sensors has been found.
show_coretemp = True
#* Set a custom mapping between core and coretemp, can be needed on certain cpus to get correct temperature for correct core.
#* Use lm-sensors or similar to see which cores are reporting temperatures on your machine.
#* Format "x:y" x=core with wrong temp, y=core with correct temp, use space as separator between multiple entries.
#* Example: "4:0 5:1 6:3"
cpu_core_map = ""
#* Which temperature scale to use, available values: "celsius", "fahrenheit", "kelvin" and "rankine".
temp_scale = "celsius"
#* Use base 10 for bits/bytes sizes, KB = 1000 instead of KiB = 1024.
base_10_sizes = False
#* Show CPU frequency.
show_cpu_freq = True
#* Draw a clock at top of screen, formatting according to strftime, empty string to disable.
#* Special formatting: /host = hostname | /user = username | /uptime = system uptime
clock_format = "%X"
#* Update main ui in background when menus are showing, set this to false if the menus is flickering too much for comfort.
background_update = True
#* Custom cpu model name, empty string to disable.
custom_cpu_name = ""
#* Optional filter for shown disks, should be full path of a mountpoint, separate multiple values with whitespace " ".
#* Only disks matching the filter will be shown. Prepend exclude= to only show disks not matching the filter. Examples: disk_filter="/boot /home/user", disks_filter="exclude=/boot /home/user"
disks_filter = ""
#* Show graphs instead of meters for memory values.
mem_graphs = True
#* Show mem box below net box instead of above.
mem_below_net = False
#* Count ZFS ARC in cached and available memory.
zfs_arc_cached = True
#* If swap memory should be shown in memory box.
show_swap = True
#* Show swap as a disk, ignores show_swap value above, inserts itself after first disk.
swap_disk = True
#* If mem box should be split to also show disks info.
show_disks = True
#* Filter out non physical disks. Set this to False to include network disks, RAM disks and similar.
only_physical = False
#* Read disks list from /etc/fstab. This also disables only_physical.
use_fstab = True
#* Setting this to True will hide all datasets, and only show ZFS pools. (IO stats will be calculated per-pool)
zfs_hide_datasets = False
#* Set to true to show available disk space for privileged users.
disk_free_priv = True
#* Toggles if io activity % (disk busy time) should be shown in regular disk usage view.
show_io_stat = True
#* Toggles io mode for disks, showing big graphs for disk read/write speeds.
io_mode = False
#* Set to True to show combined read/write io graphs in io mode.
io_graph_combined = False
#* Set the top speed for the io graphs in MiB/s (100 by default), use format "mountpoint:speed" separate disks with whitespace " ".
#* Example: "/mnt/media:100 /:20 /boot:1".
io_graph_speeds = ""
#* Set fixed values for network graphs in Mebibits. Is only used if net_auto is also set to False.
net_download = 100
net_upload = 100
#* Use network graphs auto rescaling mode, ignores any values set above and rescales down to 10 Kibibytes at the lowest.
net_auto = True
#* Sync the auto scaling for download and upload to whichever currently has the highest scale.
net_sync = True
#* Starts with the Network Interface specified here.
net_iface = ""
#* "True" shows bitrates in base 10 (Kbps, Mbps). "False" shows bitrates in binary sizes (Kibps, Mibps, etc.). "Auto" uses base_10_sizes.
base_10_bitrate = "Auto"
#* Show battery stats in top right if battery is present.
show_battery = True
#* Which battery to use if multiple are present. "Auto" for auto detection.
selected_battery = "Auto"
#* Show power stats of battery next to charge indicator.
show_battery_watts = True
#* Set loglevel for "~/.config/btop/btop.log" levels are: "ERROR" "WARNING" "INFO" "DEBUG".
#* The level set includes all lower levels, i.e. "DEBUG" will show all logging info.
log_level = "WARNING"
#* Measure PCIe throughput on NVIDIA cards, may impact performance on certain cards.
nvml_measure_pcie_speeds = True
#* Measure PCIe throughput on AMD cards, may impact performance on certain cards.
rsmi_measure_pcie_speeds = True
#* Horizontally mirror the GPU graph.
gpu_mirror_graph = True
#* Custom gpu0 model name, empty string to disable.
custom_gpu_name0 = ""
#* Custom gpu1 model name, empty string to disable.
custom_gpu_name1 = ""
#* Custom gpu2 model name, empty string to disable.
custom_gpu_name2 = ""
#* Custom gpu3 model name, empty string to disable.
custom_gpu_name3 = ""
#* Custom gpu4 model name, empty string to disable.
custom_gpu_name4 = ""
#* Custom gpu5 model name, empty string to disable.
custom_gpu_name5 = ""
{{- else if eq .chezmoi.os "linux" -}}
#? Config file for btop v. 1.3.0
#* Name of a btop++/bpytop/bashtop formatted ".theme" file, "Default" and "TTY" for builtin themes.
#* Themes should be placed in "../share/btop/themes" relative to binary or "$HOME/.config/btop/themes"
color_theme = "TTY"
#* If the theme set background should be shown, set to False if you want terminal background transparency.
theme_background = True
#* Sets if 24-bit truecolor should be used, will convert 24-bit colors to 256 color (6x6x6 color cube) if false.
truecolor = True
#* Set to true to force tty mode regardless if a real tty has been detected or not.
#* Will force 16-color mode and TTY theme, set all graph symbols to "tty" and swap out other non tty friendly symbols.
force_tty = False
#* Define presets for the layout of the boxes. Preset 0 is always all boxes shown with default settings. Max 9 presets.
#* Format: "box_name:P:G,box_name:P:G" P=(0 or 1) for alternate positions, G=graph symbol to use for box.
#* Use whitespace " " as separator between different presets.
#* Example: "cpu:0:default,mem:0:tty,proc:1:default cpu:0:braille,proc:0:tty"
presets = "cpu:1:default,proc:0:default cpu:0:default,mem:0:default,net:0:default cpu:0:block,net:0:tty"
#* Set to True to enable "h,j,k,l,g,G" keys for directional control in lists.
#* Conflicting keys for h:"help" and k:"kill" is accessible while holding shift.
vim_keys = False
#* Rounded corners on boxes, is ignored if TTY mode is ON.
rounded_corners = True
#* Default symbols to use for graph creation, "braille", "block" or "tty".
#* "braille" offers the highest resolution but might not be included in all fonts.
#* "block" has half the resolution of braille but uses more common characters.
#* "tty" uses only 3 different symbols but will work with most fonts and should work in a real TTY.
#* Note that "tty" only has half the horizontal resolution of the other two, so will show a shorter historical view.
graph_symbol = "braille"
# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty".
graph_symbol_cpu = "default"
# Graph symbol to use for graphs in gpu box, "default", "braille", "block" or "tty".
graph_symbol_gpu = "default"
# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty".
graph_symbol_mem = "default"
# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty".
graph_symbol_net = "default"
# Graph symbol to use for graphs in cpu box, "default", "braille", "block" or "tty".
graph_symbol_proc = "default"
#* Manually set which boxes to show. Available values are "cpu mem net proc" and "gpu0" through "gpu5", separate values with whitespace.
shown_boxes = "cpu mem net proc"
#* Update time in milliseconds, recommended 2000 ms or above for better sample times for graphs.
update_ms = 2000
#* Processes sorting, "pid" "program" "arguments" "threads" "user" "memory" "cpu lazy" "cpu direct",
#* "cpu lazy" sorts top process over time (easier to follow), "cpu direct" updates top process directly.
proc_sorting = "cpu direct"
#* Reverse sorting order, True or False.
proc_reversed = False
#* Show processes as a tree.
proc_tree = False
#* Use the cpu graph colors in the process list.
proc_colors = True
#* Use a darkening gradient in the process list.
proc_gradient = True
#* If process cpu usage should be of the core it's running on or usage of the total available cpu power.
proc_per_core = True
#* Show process memory as bytes instead of percent.
proc_mem_bytes = True
#* Show cpu graph for each process.
proc_cpu_graphs = True
#* Use /proc/[pid]/smaps for memory information in the process info box (very slow but more accurate)
proc_info_smaps = False
#* Show proc box on left side of screen instead of right.
proc_left = False
#* (Linux) Filter processes tied to the Linux kernel(similar behavior to htop).
proc_filter_kernel = False
#* In tree-view, always accumulate child process resources in the parent process.
proc_aggregate = False
#* Sets the CPU stat shown in upper half of the CPU graph, "total" is always available.
#* Select from a list of detected attributes from the options menu.
cpu_graph_upper = "Auto"
#* Sets the CPU stat shown in lower half of the CPU graph, "total" is always available.
#* Select from a list of detected attributes from the options menu.
cpu_graph_lower = "iowait"
#* If gpu info should be shown in the cpu box. Available values = "Auto", "On" and "Off".
show_gpu_info = "Auto"
#* Toggles if the lower CPU graph should be inverted.
cpu_invert_lower = True
#* Set to True to completely disable the lower CPU graph.
cpu_single_graph = False
#* Show cpu box at bottom of screen instead of top.
cpu_bottom = False
#* Shows the system uptime in the CPU box.
show_uptime = True
#* Show cpu temperature.
check_temp = True
#* Which sensor to use for cpu temperature, use options menu to select from list of available sensors.
cpu_sensor = "Auto"
#* Show temperatures for cpu cores also if check_temp is True and sensors has been found.
show_coretemp = True
#* Set a custom mapping between core and coretemp, can be needed on certain cpus to get correct temperature for correct core.
#* Use lm-sensors or similar to see which cores are reporting temperatures on your machine.
#* Format "x:y" x=core with wrong temp, y=core with correct temp, use space as separator between multiple entries.
#* Example: "4:0 5:1 6:3"
cpu_core_map = ""
#* Which temperature scale to use, available values: "celsius", "fahrenheit", "kelvin" and "rankine".
temp_scale = "celsius"
#* Use base 10 for bits/bytes sizes, KB = 1000 instead of KiB = 1024.
base_10_sizes = False
#* Show CPU frequency.
show_cpu_freq = True
#* Draw a clock at top of screen, formatting according to strftime, empty string to disable.
#* Special formatting: /host = hostname | /user = username | /uptime = system uptime
clock_format = "%X"
#* Update main ui in background when menus are showing, set this to false if the menus is flickering too much for comfort.
background_update = True
#* Custom cpu model name, empty string to disable.
custom_cpu_name = ""
#* Optional filter for shown disks, should be full path of a mountpoint, separate multiple values with whitespace " ".
#* Begin line with "exclude=" to change to exclude filter, otherwise defaults to "most include" filter. Example: disks_filter="exclude=/boot /home/user".
disks_filter = ""
#* Show graphs instead of meters for memory values.
mem_graphs = True
#* Show mem box below net box instead of above.
mem_below_net = False
#* Count ZFS ARC in cached and available memory.
zfs_arc_cached = True
#* If swap memory should be shown in memory box.
show_swap = True
#* Show swap as a disk, ignores show_swap value above, inserts itself after first disk.
swap_disk = True
#* If mem box should be split to also show disks info.
show_disks = True
#* Filter out non physical disks. Set this to False to include network disks, RAM disks and similar.
only_physical = False
#* Read disks list from /etc/fstab. This also disables only_physical.
use_fstab = True
#* Setting this to True will hide all datasets, and only show ZFS pools. (IO stats will be calculated per-pool)
zfs_hide_datasets = False
#* Set to true to show available disk space for privileged users.
disk_free_priv = True
#* Toggles if io activity % (disk busy time) should be shown in regular disk usage view.
show_io_stat = True
#* Toggles io mode for disks, showing big graphs for disk read/write speeds.
io_mode = False
#* Set to True to show combined read/write io graphs in io mode.
io_graph_combined = False
#* Set the top speed for the io graphs in MiB/s (100 by default), use format "mountpoint:speed" separate disks with whitespace " ".
#* Example: "/mnt/media:100 /:20 /boot:1".
io_graph_speeds = ""
#* Set fixed values for network graphs in Mebibits. Is only used if net_auto is also set to False.
net_download = 100
net_upload = 100
#* Use network graphs auto rescaling mode, ignores any values set above and rescales down to 10 Kibibytes at the lowest.
net_auto = False
#* Sync the auto scaling for download and upload to whichever currently has the highest scale.
net_sync = True
#* Starts with the Network Interface specified here.
net_iface = ""
#* Show battery stats in top right if battery is present.
show_battery = True
#* Which battery to use if multiple are present. "Auto" for auto detection.
selected_battery = "Auto"
#* Set loglevel for "~/.config/btop/btop.log" levels are: "ERROR" "WARNING" "INFO" "DEBUG".
#* The level set includes all lower levels, i.e. "DEBUG" will show all logging info.
log_level = "WARNING"
#* Measure PCIe throughput on NVIDIA cards, may impact performance on certain cards.
nvml_measure_pcie_speeds = True
#* Horizontally mirror the GPU graph.
gpu_mirror_graph = True
#* Custom gpu0 model name, empty string to disable.
custom_gpu_name0 = ""
#* Custom gpu1 model name, empty string to disable.
custom_gpu_name1 = ""
#* Custom gpu2 model name, empty string to disable.
custom_gpu_name2 = ""
#* Custom gpu3 model name, empty string to disable.
custom_gpu_name3 = ""
#* Custom gpu4 model name, empty string to disable.
custom_gpu_name4 = ""
#* Custom gpu5 model name, empty string to disable.
custom_gpu_name5 = ""
{{- end }}
+27
View File
@@ -0,0 +1,27 @@
# The current version of the config schema
version: 1
# What protocol to use when performing git operations. Supported values: ssh, https
git_protocol: https
# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.
editor:
# When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
prompt: enabled
# Preference for editor-based interactive prompting. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
prefer_editor_prompt: disabled
# A pager program to send command output to, e.g. "less". If blank, will refer to environment. Set the value to "cat" to disable the pager.
pager:
# Aliases allow you to create nicknames for gh commands
aliases:
co: pr checkout
# The path to a unix socket through which to send HTTP connections. If blank, HTTP traffic will be handled by net/http.DefaultTransport.
http_unix_socket:
# What web browser gh should use when opening URLs. If blank, will refer to environment.
browser:
# Whether to display labels using their RGB hex color codes in terminals that support truecolor. Supported values: enabled, disabled
color_labels: disabled
# Whether customizable, 4-bit accessible colors should be used. Supported values: enabled, disabled
accessible_colors: disabled
# Whether an accessible prompter should be used. Supported values: enabled, disabled
accessible_prompter: disabled
# Whether to use a animated spinner as a progress indicator. If disabled, a textual progress indicator is used instead. Supported values: enabled, disabled
spinner: enabled
+56
View File
@@ -0,0 +1,56 @@
# This is the configuration file for Ghostty.
#
# The template does not set any default options, since Ghostty ships
# with sensible defaults for all options. Users should only need to set
# options that they want to change from the default.
#
# Run `ghostty +show-config --default --docs` to view a list of
# all available config options and their default values.
#
# Additionally, each config option is also explained in detail
# on Ghostty's website, at https://ghostty.org/docs/config.
# Config syntax crash course
# ==========================
# # The config file consists of simple key-value pairs,
# # separated by equals signs.
# font-family = Iosevka
# window-padding-x = 2
#
# # Spacing around the equals sign does not matter.
# # All of these are identical:
# key=value
# key= value
# key =value
# key = value
#
# # Any line beginning with a # is a comment. It's not possible to put
# # a comment after a config option, since it would be interpreted as a
# # part of the value. For example, this will have a value of "#123abc":
# background = #123abc
#
# # Empty values are used to reset config keys to default.
# key =
#
# # Some config options have unique syntaxes for their value,
# # which is explained in the docs for that config option.
# # Just for example:
# resize-overlay-duration = 4s 200ms
shell-integration-features = sudo
keybind = shift+enter=text:\n
#font-family = "CaskaydiaMono Nerd Font Mono ExtraLight"
font-family = "JetBrainsMono NFM Thin"
font-family-italic = "JetBrainsMono NFM Thin"
font-family-bold = "JetBrainsMono NFM Light"
font-family-bold-italic = "JetBrainsMono NFM Light"
# VS Code-like visual improvements
font-size = 13
adjust-cell-height = 15%
window-padding-x = 12
window-padding-y = 8
cursor-style = bar
cursor-style-blink = true
{{ if eq .chezmoi.os "darwin" }}
background-opacity = 1.00
{{- end }}
+1
View File
@@ -0,0 +1 @@
**/.claude/settings.local.json
+35
View File
@@ -0,0 +1,35 @@
[ui]
agent_panel_sort = "spaces"
[ui.sound]
enabled = false
[ui.toast]
delivery = "herdr"
[experimental]
kitty_graphics = true
[keys]
next_tab = "shift+right"
previous_tab = "shift+left"
next_workspace = "shift+down"
previous_workspace = "shift+up"
[[keys.command]]
key = "prefix+p"
type = "plugin_action"
command = "jt.command-palette.open"
description = "Command palette"
[[keys.command]]
key = "cmd+r"
type = "plugin_action"
command = "persiyanov.reviewr.toggle"
description = "reviewr: toggle sidebar"
[[keys.command]]
key = "prefix+shift+a"
type = "plugin_action"
command = "herdr-spreader.apply"
description = "Spreader: apply layout"
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,210 @@
# LazyVim Plugin Cheatsheet
Quick reference for the custom plugins in your configuration.
---
## Git Tools (`lua/plugins/git.lua`)
### git-blame.nvim
Shows git blame information at the end of each line.
**Commands:**
- `:GitBlameToggle` - Toggle git blame on/off
- `:GitBlameEnable` - Enable git blame
- `:GitBlameDisable` - Disable git blame
- `:GitBlameOpenCommitURL` - Open commit in browser
- `:GitBlameCopySHA` - Copy commit SHA to clipboard
**Default:** Enabled on startup
---
### diffview.nvim
Advanced git diff and history viewer.
**Key Bindings:**
- `<leader>gd` - Open diff view of current changes
- `<leader>gh` - View current file's git history
- `<leader>gH` - View entire repo's git history
**Commands:**
- `:DiffviewOpen` - Open diff view
- `:DiffviewClose` - Close diff view
- `:DiffviewFileHistory` - View repo history
- `:DiffviewFileHistory %` - View current file history
- `:DiffviewRefresh` - Refresh the diff view
**In Diffview:**
- `]c` / `[c` - Jump to next/previous change
- `<tab>` / `<S-tab>` - Select next/previous file
- `gf` - Open file in new tab
- `q` - Close diffview
---
## UI Enhancements (`lua/plugins/ui-enhancements.lua`)
### render-markdown.nvim
Renders markdown with better formatting while editing.
**Features:**
- Automatically styles headers, lists, code blocks
- Works only in markdown files
- No commands needed - always active in `.md` files
**Toggle:** Disable by setting `enabled = false` in config
---
### hlargs.nvim
Highlights function arguments with different colors.
**Features:**
- Automatically highlights function parameters
- Helps distinguish arguments visually
- Works across all supported languages
**No commands needed** - always active
---
### twilight.nvim
Dims inactive code to focus on current block.
**Key Bindings:**
- `<leader>ut` - Toggle Twilight mode
**Commands:**
- `:Twilight` - Toggle twilight
- `:TwilightEnable` - Enable twilight
- `:TwilightDisable` - Disable twilight
**Best for:** Focusing on specific functions or code blocks
---
### theme-hub.nvim
Browse and switch colorschemes with live preview.
**Key Bindings:**
- `<leader>uT` - Open Theme Hub
**Commands:**
- `:ThemeHub` - Open theme browser
**In Theme Hub:**
- `j` / `k` - Navigate themes
- `<CR>` - Apply selected theme
- `<Esc>` / `q` - Close without applying
---
## Search Tools (`lua/plugins/search.lua`)
### nvim-hlslens
Enhanced search highlighting with match counts.
**Key Bindings:**
- `n` - Next search result (enhanced)
- `N` - Previous search result (enhanced)
- `*` - Search word under cursor (enhanced)
- `#` - Search word backward (enhanced)
- `g*` / `g#` - Search without word boundaries
**Features:**
- Shows match count (e.g., "3/15")
- Floating window near cursor
- Automatically clears after cursor movement
---
### search-replace.nvim
Advanced multi-buffer search and replace.
**Single Buffer Operations:**
- `<leader>rs` - Search/replace in visual selection
- `<leader>ro` - Search/replace (open prompt)
- `<leader>rw` - Search/replace word under cursor
- `<leader>rW` - Search/replace WORD under cursor
- `<leader>re` - Search/replace expression
- `<leader>rf` - Search/replace filename
**Multi-Buffer Operations:**
- `<leader>rbs` - Search/replace selections (all buffers)
- `<leader>rbo` - Search/replace open (all buffers)
- `<leader>rbw` - Search/replace word (all buffers)
- `<leader>rbW` - Search/replace WORD (all buffers)
- `<leader>rbe` - Search/replace expression (all buffers)
- `<leader>rbf` - Search/replace filename (all buffers)
**Commands:**
- `:SearchReplaceSingleBuffer` - Single buffer mode
- `:SearchReplaceMultiBuffer` - Multi-buffer mode
- `:SearchReplaceWithinVisualSelection` - Within selection
---
## File Type Tools (`lua/plugins/filetypes.lua`)
### csvview.nvim
Formats and aligns CSV/TSV files for better readability.
**Key Bindings:**
- `<leader>cv` - Toggle CSV view (only in CSV/TSV files)
**Commands:**
- `:CsvViewEnable` - Enable CSV formatting
- `:CsvViewDisable` - Disable CSV formatting
- `:CsvViewToggle` - Toggle CSV view
**File Types:** Automatically works with `.csv` and `.tsv` files
---
## Built-in LazyVim Extras
These are enabled via `lazyvim.json` and have their own keybindings:
### Telescope
- `<leader>ff` - Find files
- `<leader>fg` - Live grep
- `<leader>fb` - Browse buffers
- `<leader>fh` - Help tags
### Neo-tree
- `<leader>e` - Toggle file explorer
- `<leader>E` - Toggle file explorer (current file)
### Harpoon2
- `<leader>h` - Harpoon menu
- `<leader>a` - Add file to harpoon
### Aerial (Code Outline)
- `<leader>cs` - Open symbols outline
### Project Management
- `<leader>fp` - Find project
---
## Tips
1. **Enable/Disable Plugins:** Edit the respective file in `lua/plugins/` and set `enabled = false`
2. **View All Keymaps:** Press `<leader>sk` (search keymaps) in Telescope
3. **Check Plugin Status:** Run `:Lazy` to see installed plugins
4. **Update Plugins:** Run `:Lazy update`
5. **View Help:** Most plugins have `:help <plugin-name>` documentation
---
## Plugin Files Location
```
~/.config/nvim/lua/plugins/
├── git.lua # Git blame & diffview
├── ui-enhancements.lua # Render-markdown, hlargs, twilight, theme-hub
├── search.lua # Hlslens, search-replace
├── filetypes.lua # CSV viewer
└── markdown.lua # Markdown-specific config
```
+4
View File
@@ -0,0 +1,4 @@
# 💤 LazyVim
A starter template for [LazyVim](https://github.com/LazyVim/LazyVim).
Refer to the [documentation](https://lazyvim.github.io/installation) to get started.
+9
View File
@@ -0,0 +1,9 @@
tt.*
.tests
doc/tags
debug
.repro
foo.*
*.log
data
.claude
+15
View File
@@ -0,0 +1,15 @@
{
"neodev": {
"library": {
"enabled": true,
"plugins": true
}
},
"neoconf": {
"plugins": {
"lua_ls": {
"enabled": true
}
}
}
}
+2
View File
@@ -0,0 +1,2 @@
-- bootstrap lazy.nvim, LazyVim and your plugins
require("config.lazy")
+28
View File
@@ -0,0 +1,28 @@
{
"extras": [
"lazyvim.plugins.extras.ai.claudecode",
"lazyvim.plugins.extras.coding.yanky",
"lazyvim.plugins.extras.editor.aerial",
"lazyvim.plugins.extras.editor.harpoon2",
"lazyvim.plugins.extras.editor.illuminate",
"lazyvim.plugins.extras.editor.mini-diff",
"lazyvim.plugins.extras.editor.neo-tree",
"lazyvim.plugins.extras.editor.refactoring",
"lazyvim.plugins.extras.editor.telescope",
"lazyvim.plugins.extras.lang.docker",
"lazyvim.plugins.extras.lang.go",
"lazyvim.plugins.extras.lang.json",
"lazyvim.plugins.extras.lang.python",
"lazyvim.plugins.extras.lang.yaml",
"lazyvim.plugins.extras.ui.smear-cursor",
"lazyvim.plugins.extras.ui.treesitter-context",
"lazyvim.plugins.extras.util.chezmoi",
"lazyvim.plugins.extras.util.dot",
"lazyvim.plugins.extras.util.project"
],
"install_version": 8,
"news": {
"NEWS.md": "11866"
},
"version": 8
}
@@ -0,0 +1,8 @@
-- Autocmds are automatically loaded on the VeryLazy event
-- Default autocmds that are always set: https://github.com/LazyVim/LazyVim/blob/main/lua/lazyvim/config/autocmds.lua
--
-- Add any additional autocmds here
-- with `vim.api.nvim_create_autocmd`
--
-- Or remove existing autocmds by their group name (which is prefixed with `lazyvim_` for the defaults)
-- e.g. vim.api.nvim_del_augroup_by_name("lazyvim_wrap_spell")
@@ -0,0 +1,15 @@
-- Keymaps are automatically loaded on the VeryLazy event
-- Default keymaps that are always set: https://github.com/LazyVim/LazyVim/blob/main/lua/lazyvim/config/keymaps.lua
-- Add any additional keymaps here
--
-- -- Copy relative path
vim.keymap.set("n", "<leader>cy", function()
vim.fn.setreg("+", vim.fn.expand("%"))
print("Copied relative path: " .. vim.fn.expand("%"))
end, { desc = "Copy relative path" })
-- Copy absolute path
vim.keymap.set("n", "<leader>cY", function()
vim.fn.setreg("+", vim.fn.expand("%:p"))
print("Copied absolute path: " .. vim.fn.expand("%:p"))
end, { desc = "Copy absolute path" })
@@ -0,0 +1,53 @@
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not (vim.uv or vim.loop).fs_stat(lazypath) then
local lazyrepo = "https://github.com/folke/lazy.nvim.git"
local out = vim.fn.system({ "git", "clone", "--filter=blob:none", "--branch=stable", lazyrepo, lazypath })
if vim.v.shell_error ~= 0 then
vim.api.nvim_echo({
{ "Failed to clone lazy.nvim:\n", "ErrorMsg" },
{ out, "WarningMsg" },
{ "\nPress any key to exit..." },
}, true, {})
vim.fn.getchar()
os.exit(1)
end
end
vim.opt.rtp:prepend(lazypath)
require("lazy").setup({
spec = {
-- add LazyVim and import its plugins
{ "LazyVim/LazyVim", import = "lazyvim.plugins" },
-- import/override with your plugins
{ import = "plugins" },
},
defaults = {
-- By default, only LazyVim plugins will be lazy-loaded. Your custom plugins will load during startup.
-- If you know what you're doing, you can set this to `true` to have all your custom plugins lazy-loaded by default.
lazy = false,
-- It's recommended to leave version=false for now, since a lot the plugin that support versioning,
-- have outdated releases, which may break your Neovim install.
version = false, -- always use the latest git commit
-- version = "*", -- try installing the latest stable version for plugins that support semver
},
install = { colorscheme = { "tokyonight", "habamax" } },
checker = {
enabled = true, -- check for plugin updates periodically
notify = false, -- notify on update
}, -- automatically check for plugin updates
performance = {
rtp = {
-- disable some rtp plugins
disabled_plugins = {
"gzip",
-- "matchit",
-- "matchparen",
-- "netrwPlugin",
"tarPlugin",
"tohtml",
"tutor",
"zipPlugin",
},
},
},
})
@@ -0,0 +1,82 @@
-- Options are automatically loaded before lazy.nvim startup
-- Default options that are always set: https://github.com/LazyVim/LazyVim/blob/main/lua/lazyvim/config/options.lua
-- Add any additional options here
-- Clipboard provider. LazyVim disables `clipboard=unnamedplus` under SSH, so
-- select the right provider and explicitly re-enable clipboard-linked yanks:
-- * tmux -> tmux's own clipboard buffer (readable)
-- * SSH/remote -> OSC 52 copy-only (see note below)
-- * local macOS -> native pbcopy/pbpaste.
-- Do NOT use OSC 52 locally: yanky's `sync_with_ring` reads the system
-- clipboard, and OSC 52 reads depend on the terminal answering a query, which
-- Ghostty refuses by default -> OSC 52 error + yanky stack trace. Neovim 0.12
-- also auto-prefers OSC 52 over pbcopy, so the macOS provider must be explicit.
if vim.env.TMUX then
vim.g.clipboard = "tmux"
elseif vim.env.SSH_CONNECTION then
-- Remote session (bare SSH, or a multiplexer like herdr that exports no env
-- var of its own). Copy via OSC 52 so yanks reach the OUTER terminal's
-- clipboard, but NEVER read the clipboard back: OSC 52 reads require the
-- terminal to answer a query, which Warp/herdr don't, so a read returns stale
-- data (or hangs) and blows up yanky's ring sync. Trade-off: pasting FROM the
-- local host INTO nvim over SSH won't work here -- use the terminal's own
-- paste in insert mode.
--
-- The paste function below must NOT call vim.fn.getreg('"')/'*'/'+' itself:
-- Neovim's clipboard provider (autoload/provider/clipboard.vim) guards
-- against re-entrant provider calls (`s:here`, see nvim#7184), so a getreg()
-- of a clipboard-linked register issued from *inside* a registered paste()
-- callback is swallowed and silently returns empty -- not the register's
-- real content. Cache what copy() was last given instead, and have paste()
-- return that cached value directly.
local osc52 = require("vim.ui.clipboard.osc52")
local last = { lines = {}, regtype = "" }
local function make_copy(reg)
local osc52_copy = osc52.copy(reg)
return function(lines, regtype)
last.lines, last.regtype = lines, regtype
osc52_copy(lines, regtype)
end
end
local function paste_from_cache()
return { last.lines, last.regtype }
end
vim.g.clipboard = {
name = "osc52-copyonly",
copy = { ["+"] = make_copy("+"), ["*"] = make_copy("*") },
paste = { ["+"] = paste_from_cache, ["*"] = paste_from_cache },
cache_enabled = 0,
}
elseif vim.fn.has("mac") == 1 then
vim.g.clipboard = {
name = "pbcopy",
copy = { ["+"] = "pbcopy", ["*"] = "pbcopy" },
paste = { ["+"] = "pbpaste", ["*"] = "pbpaste" },
cache_enabled = 0,
}
end
-- Make ordinary y/Y use the selected provider. LazyVim applies its SSH default
-- after this file is read, so restore the option once startup has settled. The
-- schedule keeps this callback after LazyVim's own VeryLazy clipboard callback.
vim.api.nvim_create_autocmd("User", {
pattern = "VeryLazy",
once = true,
callback = function()
vim.schedule(function()
vim.opt.clipboard = "unnamedplus"
end)
end,
})
-- VS Code-like visual improvements
vim.opt.cursorline = true -- Highlight current line
-- vim.opt.colorcolumn = "80,120" -- Disabled: renders poorly with some themes
vim.opt.scrolloff = 8 -- Keep 8 lines visible above/below cursor
vim.opt.sidescrolloff = 8 -- Keep 8 columns visible left/right
vim.opt.smoothscroll = true -- Smooth scrolling
-- Make Shift+movement and mouse selections behave like a conventional editor.
-- Keep `cmd` out of selectmode so v/V continue to enter Vim's Visual mode.
vim.opt.keymodel = { "startsel", "stopsel" }
vim.opt.selectmode = { "key", "mouse" }
@@ -0,0 +1,37 @@
local completion_state = {
enabled = true, -- current state
backup = { -- store the original values so we can restore them
omnifunc = nil,
completefunc = nil,
completeopt = nil,
},
}
vim.keymap.set("n", "<leader>co", function()
if completion_state.enabled then
-- ----- DISABLE -----
completion_state.backup.omnifunc = vim.opt_local.omnifunc:get()
completion_state.backup.completefunc = vim.opt_local.completefunc:get()
completion_state.backup.completeopt = vim.opt_local.completeopt:get()
-- Clear the sources
vim.opt_local.omnifunc = ""
vim.opt_local.completefunc = ""
-- Hide the menu even if something tries to show it
vim.opt_local.completeopt = { "noinsert", "noselect" }
completion_state.enabled = false
print("⛔ Autocompletion disabled")
else
-- ----- ENABLE -----
vim.opt_local.omnifunc = completion_state.backup.omnifunc
vim.opt_local.completefunc = completion_state.backup.completefunc
-- Restore the previous `completeopt` (or a safe default)
vim.opt_local.completeopt = completion_state.backup.completeopt or { "menu", "preview", "noinsert", "noselect" }
completion_state.enabled = true
print("✅ Autocompletion re‑enabled")
end
end, { desc = "Toggle all autocompletion (omnifunc, completefunc, menu)" })
@@ -0,0 +1,35 @@
return {
dir = vim.fn.expand("~/code/nvim-ai-assist"),
name = "nvim_ai_assist",
lazy = false,
cmd = { "AI", "AIHealth", "AIRefreshContext" },
keys = {
{ "<leader>ai", ":AI<CR>", desc = "AI command (prompt)", mode = "n" },
{ "<leader>aA", ":AI!<CR>", desc = "AI command (auto-execute)", mode = "n" },
{ "<leader>ah", ":AIHealth<CR>", desc = "AI health + context stats", mode = "n" },
},
config = function()
require("nvim_ai_assist").setup({
-- Active: Z.AI Coding Plan (uses ZAI_API_KEY from env)
provider = "zai-coding",
model = "glm-4.7",
max_tokens = 300,
temperature = 0.05,
debug = true, -- writes request/response to ~/.cache/nvim/nvim_ai_assist.log; :AILog to view
context = {
enabled = true,
max_chars = 1500,
refresh_seconds = 60,
},
-- Local LM Studio fallback — uncomment to use, and comment out the
-- provider/model lines above.
-- The host is deliberately not written down here: this repo is public,
-- and a LAN address is one of the things the tier split exists to keep
-- out of it. Put yours in and do not commit it back.
-- endpoint = "http://<lm-studio-host>:1234/v1/chat/completions",
-- api_key = "lm-studio",
-- model = "qwen3-almost-human-y1-7b",
})
end,
}
@@ -0,0 +1,32 @@
-- Tame blink.cmp completions for prose:
-- * drop the `buffer` (next-word) source everywhere
-- * markdown/md-render buffers use LSP+path only (no snippet callout noise)
-- * disable friendly-snippets' global snippets (datetime/dateMDY/lorem/...)
-- Inline AI suggestions are handled by minuet instead (see minuet.lua).
return {
"saghen/blink.cmp",
opts = function(_, opts)
opts.sources = opts.sources or {}
-- Drop the buffer (next-word) source everywhere.
opts.sources.default = vim.tbl_filter(function(source)
return source ~= "buffer"
end, opts.sources.default or { "lsp", "path", "snippets" })
-- Markdown editing (ft=markdown) and md-render's render buffer (ft=md-render):
-- LSP+path only, so callout snippets like !WARNING don't pop up. md-render
-- flips the buffer filetype between these two as you edit, so cover both.
opts.sources.per_filetype = opts.sources.per_filetype or {}
opts.sources.per_filetype.markdown = { "lsp", "path" }
opts.sources.per_filetype["md-render"] = { "lsp", "path" }
-- Kill friendly-snippets' GLOBAL snippets (global.json: datetime, dateMDY,
-- lorem, etc.). These load in every filetype regardless of the per_filetype
-- rules above, which is why they leaked through while editing markdown.
-- Per-language snippets (friendly_snippets) are unaffected.
opts.sources.providers = opts.sources.providers or {}
opts.sources.providers.snippets = opts.sources.providers.snippets or {}
opts.sources.providers.snippets.opts = opts.sources.providers.snippets.opts or {}
opts.sources.providers.snippets.opts.global_snippets = {}
end,
}
@@ -0,0 +1,14 @@
return {
"sindrets/diffview.nvim",
cmd = { "DiffviewOpen", "DiffviewFileHistory", "DiffviewClose" },
keys = {
{ "<leader>dv", "<cmd>DiffviewOpen<cr>", desc = "Open diffview" },
{ "<leader>ds", "<cmd>DiffviewOpen --staged<cr>", desc = "Diffview staged" },
{ "<leader>dh", "<cmd>DiffviewFileHistory %<cr>", desc = "File history (current)" },
{ "<leader>dH", "<cmd>DiffviewFileHistory<cr>", desc = "File history (all)" },
},
config = function()
require("diffview").setup({})
-- removed the vim.keymap.set lines from here
end,
}
@@ -0,0 +1,197 @@
-- since this is just an example spec, don't actually load anything here and return an empty spec
-- stylua: ignore
if true then return {} end
-- every spec file under the "plugins" directory will be loaded automatically by lazy.nvim
--
-- In your plugin files, you can:
-- * add extra plugins
-- * disable/enabled LazyVim plugins
-- * override the configuration of LazyVim plugins
return {
-- add gruvbox
{ "ellisonleao/gruvbox.nvim" },
-- Configure LazyVim to load gruvbox
{
"LazyVim/LazyVim",
opts = {
colorscheme = "gruvbox",
},
},
-- change trouble config
{
"folke/trouble.nvim",
-- opts will be merged with the parent spec
opts = { use_diagnostic_signs = true },
},
-- disable trouble
{ "folke/trouble.nvim", enabled = false },
-- override nvim-cmp and add cmp-emoji
{
"hrsh7th/nvim-cmp",
dependencies = { "hrsh7th/cmp-emoji" },
---@param opts cmp.ConfigSchema
opts = function(_, opts)
table.insert(opts.sources, { name = "emoji" })
end,
},
-- change some telescope options and a keymap to browse plugin files
{
"nvim-telescope/telescope.nvim",
keys = {
-- add a keymap to browse plugin files
-- stylua: ignore
{
"<leader>fp",
function() require("telescope.builtin").find_files({ cwd = require("lazy.core.config").options.root }) end,
desc = "Find Plugin File",
},
},
-- change some options
opts = {
defaults = {
layout_strategy = "horizontal",
layout_config = { prompt_position = "top" },
sorting_strategy = "ascending",
winblend = 0,
},
},
},
-- add pyright to lspconfig
{
"neovim/nvim-lspconfig",
---@class PluginLspOpts
opts = {
---@type lspconfig.options
servers = {
-- pyright will be automatically installed with mason and loaded with lspconfig
pyright = {},
},
},
},
-- add tsserver and setup with typescript.nvim instead of lspconfig
{
"neovim/nvim-lspconfig",
dependencies = {
"jose-elias-alvarez/typescript.nvim",
init = function()
require("lazyvim.util").lsp.on_attach(function(_, buffer)
-- stylua: ignore
vim.keymap.set( "n", "<leader>co", "TypescriptOrganizeImports", { buffer = buffer, desc = "Organize Imports" })
vim.keymap.set("n", "<leader>cR", "TypescriptRenameFile", { desc = "Rename File", buffer = buffer })
end)
end,
},
---@class PluginLspOpts
opts = {
---@type lspconfig.options
servers = {
-- tsserver will be automatically installed with mason and loaded with lspconfig
tsserver = {},
},
-- you can do any additional lsp server setup here
-- return true if you don't want this server to be setup with lspconfig
---@type table<string, fun(server:string, opts:_.lspconfig.options):boolean?>
setup = {
-- example to setup with typescript.nvim
tsserver = function(_, opts)
require("typescript").setup({ server = opts })
return true
end,
-- Specify * to use this function as a fallback for any server
-- ["*"] = function(server, opts) end,
},
},
},
-- for typescript, LazyVim also includes extra specs to properly setup lspconfig,
-- treesitter, mason and typescript.nvim. So instead of the above, you can use:
{ import = "lazyvim.plugins.extras.lang.typescript" },
-- add more treesitter parsers
{
"nvim-treesitter/nvim-treesitter",
opts = {
ensure_installed = {
"bash",
"html",
"javascript",
"json",
"lua",
"markdown",
"markdown_inline",
"python",
"query",
"regex",
"tsx",
"typescript",
"vim",
"yaml",
},
},
},
-- since `vim.tbl_deep_extend`, can only merge tables and not lists, the code above
-- would overwrite `ensure_installed` with the new value.
-- If you'd rather extend the default config, use the code below instead:
{
"nvim-treesitter/nvim-treesitter",
opts = function(_, opts)
-- add tsx and treesitter
vim.list_extend(opts.ensure_installed, {
"tsx",
"typescript",
})
end,
},
-- the opts function can also be used to change the default opts:
{
"nvim-lualine/lualine.nvim",
event = "VeryLazy",
opts = function(_, opts)
table.insert(opts.sections.lualine_x, {
function()
return "😄"
end,
})
end,
},
-- or you can return new options to override all the defaults
{
"nvim-lualine/lualine.nvim",
event = "VeryLazy",
opts = function()
return {
--[[add your custom lualine config here]]
}
end,
},
-- use mini.starter instead of alpha
{ import = "lazyvim.plugins.extras.ui.mini-starter" },
-- add jsonls and schemastore packages, and setup treesitter for json, json5 and jsonc
{ import = "lazyvim.plugins.extras.lang.json" },
-- add any tools you want to have installed below
{
"williamboman/mason.nvim",
opts = {
ensure_installed = {
"stylua",
"shellcheck",
"shfmt",
"flake8",
},
},
},
}
@@ -0,0 +1,36 @@
-- Filetype-specific plugins
return {
-- CSV file viewer with column alignment
-- Automatically formats CSV files for better readability
-- Toggle with :CsvViewEnable, :CsvViewDisable
{
"hat0uma/csvview.nvim",
ft = { "csv", "tsv" },
enabled = true, -- Set to false to disable
opts = {
parser = {
async = true,
delimiter = {
default = ",",
ft = {
tsv = "\t",
},
},
comments = {
-- Example: Set comment prefix for specific filetypes
-- ft = {
-- csv = "#",
-- },
},
},
view = {
min_column_width = 5,
spacing = 2,
display_mode = "border", -- Options: "highlight", "border"
},
},
keys = {
{ "<leader>cv", "<cmd>CsvViewToggle<cr>", desc = "Toggle CSV View", ft = { "csv", "tsv" } },
},
},
}
@@ -0,0 +1,36 @@
-- Git enhancement plugins
return {
-- Git blame annotations in virtual text
-- Shows git blame info at the end of each line
-- Toggle with :GitBlameToggle
{
"f-person/git-blame.nvim",
event = "VeryLazy",
opts = {
enabled = true, -- Start enabled
message_template = " <summary> • <date> • <author>",
date_format = "%r",
virtual_text_column = 80,
},
},
-- Advanced git diff viewer
-- Better git diff interface with file history
-- Open with :DiffviewOpen, :DiffviewFileHistory
{
"sindrets/diffview.nvim",
cmd = { "DiffviewOpen", "DiffviewClose", "DiffviewToggleFiles", "DiffviewFocusFiles", "DiffviewFileHistory" },
opts = {
enhanced_diff_hl = true,
view = {
default = { layout = "diff2_horizontal" },
file_history = { layout = "diff2_horizontal" },
},
},
keys = {
{ "<leader>gd", "<cmd>DiffviewOpen<cr>", desc = "Open Diffview" },
{ "<leader>gh", "<cmd>DiffviewFileHistory %<cr>", desc = "File History" },
{ "<leader>gH", "<cmd>DiffviewFileHistory<cr>", desc = "Repo History" },
},
},
}
@@ -0,0 +1,861 @@
-- Markdown rendering with inline mermaid via md-render.nvim.
--
-- Why not snacks.image / diagram.nvim / image.nvim?
-- Those all try to overlay the rendered image on top of the source fenced
-- code block. snacks issue #1384 ("mermaid blocks text behind it") was
-- closed as "not planned" — overlap-in-source is a fundamental limitation
-- of the current kitty-graphics-protocol design in nvim.
--
-- md-render takes the opposite approach: render mode is a *separate buffer*.
-- Normal mode -> auto-swap to render (full diagrams, no source text in the way).
-- Insert mode -> swap back to source so we can edit. The hop is per-buffer.
--
-- Ghostty itself is verified by the plugin; the SSH transport shim below is
-- local glue so the terminal never has to read remote-only image paths.
local function use_direct_kitty_bytes()
return true
end
local function image_debug(message)
if vim.env.MD_RENDER_IMAGE_DEBUG ~= "1" then return end
local path = vim.fn.stdpath("cache") .. "/md-render-image-debug.log"
local line = os.date("%Y-%m-%d %H:%M:%S") .. " " .. message .. "\n"
vim.fn.writefile({ line }, path, "a")
end
return {
{
"delphinus/md-render.nvim",
version = "*",
ft = { "markdown" },
dependencies = {
{ "nvim-tree/nvim-web-devicons", version = "*" },
},
init = function()
-- Ghostty does not normally forward TERM_PROGRAM /
-- GHOSTTY_RESOURCES_DIR into the remote shell. Neovim 0.12 therefore
-- cannot prove Kitty graphics support from the remote side on its own.
-- Herdr panes expose HERDR_ENV=1, but the underlying display path is
-- still Ghostty-compatible when kitty_graphics is enabled.
--
-- Keep the terminal identity as Ghostty. md-render uses that identity to
-- avoid Ghostty's flaky stored-image re-placement path during redraws.
if use_direct_kitty_bytes() and not vim.env.TERM_PROGRAM then
vim.env.TERM_PROGRAM = "ghostty"
end
-- In auto mode the render view is a separate, nomodifiable buffer, so
-- normal-mode editing (dd/x/ciw/p/…) does nothing — you'd have to toggle
-- to source first. md-render already solves this for insert-entry keys
-- (i/I/a/A/o/O): pressing one hops to the source at the mapped line and
-- replays the key (see AUTO_INSERT_KEYS in the plugin). It intentionally
-- leaves operator/normal-mode keys alone.
--
-- We extend that same hop to editing keys, scoped buffer-locally to the
-- render buffer (ft=md-render) only — the real source buffer is never
-- touched. Press dd/x/p/etc. while viewing the render and it transparently
-- drops to source on the right line and performs the edit. (Pure-motion
-- keys h/j/k/w/G/… are deliberately NOT mapped, so you can still navigate
-- the render freely.) Yank keys stay in the read-only render buffer so
-- copying text does not dismiss rendered diagrams. Visual-entry keys hop
-- to source so the resulting selection can be changed safely.
-- NOTE: the plugin sets the render buffer's filetype under
-- `eventignore=all` (preview.lua, so markdown ftplugins/treesitter don't
-- clobber the pre-rendered content), so a `FileType md-render` autocmd
-- never fires. Hook `BufEnter` instead — it fires when `toggle` swaps the
-- render buffer into the window — and guard on filetype + a buffer flag so
-- the maps install exactly once per render buffer.
local HOP_KEYS = {
"d", "D", "c", "C", "x", "X", "s", "S", "r", "R",
"p", "P", "J", ">", "<", "=", "u", "v", "V", ".",
}
local hop_group = vim.api.nvim_create_augroup("MdRenderEditHop", { clear = true })
vim.api.nvim_create_autocmd({ "BufEnter", "BufWinEnter" }, {
group = hop_group,
desc = "md-render: editing keys hop to source then replay",
callback = function(ev)
if vim.bo[ev.buf].filetype ~= "md-render" then return end
if vim.b[ev.buf].md_render_hop_installed then return end
vim.b[ev.buf].md_render_hop_installed = true
for _, key in ipairs(HOP_KEYS) do
vim.keymap.set("n", key, function()
-- Preserve a leading count and explicit register (e.g. 3dd, "ayy)
-- so the replayed operator behaves like the keystroke you typed.
local count = vim.v.count
local reg = vim.v.register
local prefix = (reg and reg ~= '"') and ('"' .. reg) or ""
if count > 0 then prefix = prefix .. tostring(count) end
-- toggle() is synchronous: on return the window already shows the
-- source buffer with the cursor on the mapped line. Feed the key
-- *synchronously* (no vim.schedule) so it enters the typeahead
-- before any follow-up keystroke. That keeps multi-key operators
-- in order — `d` becomes operator-pending and your next key (e.g.
-- the second `d`, or `w`, `ip`) completes it natively in source.
-- (The plugin schedules its insert-entry hop because those are
-- single keys that immediately switch to Insert mode; operators
-- need the ordering guarantee a sync feed provides.)
require("md-render").preview.toggle()
if key == "v" or key == "V" then
-- Enter Visual mode before the user's next queued key arrives.
-- feedkeys() would put v/V behind that key, turning `Vd` into
-- `dV`; :normal! establishes the selection synchronously.
local visual_count = count > 0 and tostring(count) or ""
vim.cmd("normal! " .. visual_count .. key)
else
vim.api.nvim_feedkeys(prefix .. key, "n", false)
end
end, {
buffer = ev.buf,
noremap = true,
silent = true,
desc = "md-render: hop to source then " .. key,
})
end
end,
})
end,
keys = {
{
"<leader>mp",
function() require("md-render").preview.show({ max_width = 140 }) end,
ft = "markdown",
desc = "Markdown preview (wide float toggle)",
},
{
"<leader>mt",
function() require("md-render").preview.show_tab({ max_width = 140 }) end,
ft = "markdown",
desc = "Markdown preview (wide tab toggle)",
},
{
"<leader>ms",
function() require("md-render").preview.split({ max_width = 140 }) end,
ft = "markdown",
desc = "Markdown source/render split",
},
{
"<leader>mr",
function() require("md-render").preview.toggle({ max_width = 140 }) end,
ft = "markdown",
desc = "Markdown render toggle in-place",
},
{ "<leader>md", "<Plug>(md-render-demo)", desc = "Markdown render demo" },
},
config = function()
local function patch_mermaid_rendering()
local img = require("md-render.image")
if img._thembones_mermaid_patch then return end
img._thembones_mermaid_patch = true
local MERMAID_SCALE = "4"
local MERMAID_ROWS = 45
local original_calc_display_size = img.calc_display_size
img.calc_display_size = function(img_w, img_h, max_cols, max_rows)
if max_rows == 25 then
max_rows = MERMAID_ROWS
end
return original_calc_display_size(img_w, img_h, max_cols, max_rows)
end
local function mermaid_cache_dir()
local dir = vim.fn.stdpath("cache") .. "/md-render/mermaid"
vim.fn.mkdir(dir, "p")
return dir
end
local function mermaid_theme_args()
local bg = vim.o.background
local hl = vim.api.nvim_get_hl(0, { name = "NormalFloat", link = false })
if not hl.bg then
hl = vim.api.nvim_get_hl(0, { name = "Normal", link = false })
end
local bg_color = hl.bg
if bg == "dark" then
return "dark", bg_color and string.format("#%06x", bg_color) or "#1e1e2e"
end
return "default", bg_color and string.format("#%06x", bg_color) or "#ffffff"
end
local function mermaid_cache_path(source)
local theme, bg_hex = mermaid_theme_args()
local key = table.concat({ source, theme, bg_hex, "scale=" .. MERMAID_SCALE }, "|")
local hash = vim.fn.sha256(key):sub(1, 16)
return mermaid_cache_dir() .. "/" .. hash .. ".png"
end
local function mmdc_cmd(input_path, output_path)
local cmd
if vim.fn.executable("mmdc") == 1 then
cmd = { "mmdc" }
elseif vim.fn.executable("npx") == 1 then
cmd = { "npx", "-y", "@mermaid-js/mermaid-cli" }
else
return nil
end
local theme, bg_hex = mermaid_theme_args()
vim.list_extend(cmd, {
"-i", input_path,
"-o", output_path,
"-t", theme,
"-b", bg_hex,
"-s", MERMAID_SCALE,
})
return cmd
end
img.get_mermaid_cached = function(source)
local path = mermaid_cache_path(source)
if vim.fn.filereadable(path) == 1 then
return path
end
return nil
end
img.render_mermaid = function(source)
local cache_path = mermaid_cache_path(source)
if vim.fn.filereadable(cache_path) == 1 then
return cache_path
end
local tmp_input = vim.fn.tempname() .. ".mmd"
local f = io.open(tmp_input, "w")
if not f then return nil end
f:write(source)
f:close()
local cmd = mmdc_cmd(tmp_input, cache_path)
if not cmd then
os.remove(tmp_input)
return nil
end
vim.system(cmd, { text = true, timeout = 30000 }):wait()
os.remove(tmp_input)
if vim.fn.filereadable(cache_path) == 1 then
return cache_path
end
return nil
end
-- Callbacks per in-flight cache path, so concurrent requests for the
-- same diagram share one mmdc process instead of racing on the output.
local mermaid_inflight = {}
img.render_mermaid_async = function(source, callback)
local cache_path = mermaid_cache_path(source)
if vim.fn.filereadable(cache_path) == 1 then
callback(cache_path)
return
end
if mermaid_inflight[cache_path] then
table.insert(mermaid_inflight[cache_path], callback)
return
end
mermaid_inflight[cache_path] = { callback }
local function finish(result)
local callbacks = mermaid_inflight[cache_path]
mermaid_inflight[cache_path] = nil
for _, cb in ipairs(callbacks) do
cb(result)
end
end
local tmp_input = vim.fn.tempname() .. ".mmd"
local f = io.open(tmp_input, "w")
if not f then
finish(nil)
return
end
f:write(source)
f:close()
local cmd = mmdc_cmd(tmp_input, cache_path)
if not cmd then
os.remove(tmp_input)
finish(nil)
return
end
vim.system(cmd, { text = true, timeout = 30000 }, function()
vim.schedule(function()
os.remove(tmp_input)
if vim.fn.filereadable(cache_path) == 1 then
finish(cache_path)
else
finish(nil)
end
end)
end)
end
end
patch_mermaid_rendering()
-- md-render only processes placements near the viewport on first paint,
-- and its WinScrolled retry loop skips placements without a `path`.
-- Uncached mermaid placements carry only `mermaid_source`, so a diagram
-- that starts off-screen never renders at all, no matter how far you
-- scroll. Eagerly kick off rendering for every mermaid placement when
-- images are set up: the plugin's own rebuild-on-complete then swaps in
-- a normal `path` placement, which its retry loop handles.
local function patch_offscreen_mermaid()
local du = require("md-render.display_utils")
if du._thembones_offscreen_mermaid_patch then return end
du._thembones_offscreen_mermaid_patch = true
local original_setup_images = du.setup_images
du.setup_images = function(win, content, ns, opts)
local state = original_setup_images(win, content, ns, opts)
if state and state.placements and state.process_placement then
vim.schedule(function()
for _, placement in ipairs(state.placements) do
if placement.mermaid_source then
local ok, err = pcall(state.process_placement, placement)
if not ok then
image_debug("offscreen mermaid eager render err=" .. tostring(err))
end
end
end
end)
end
return state
end
end
patch_offscreen_mermaid()
-- md-render's Ghostty path intentionally re-transmits images on every
-- placement so redraws stay visible. Its stock transport uses `t=f`,
-- which means "the terminal reads this file path". Over SSH that path is
-- remote-only, so Ghostty cannot load it. In Herdr/Ghostty, file-path
-- transfer can reserve the image area without painting the pixels, so
-- avoid that path there too. Keep Ghostty's placement model, but switch
-- transport to direct PNG bytes (`t=d`).
if use_direct_kitty_bytes() then
image_debug("direct-byte override enabled")
local img = require("md-render.image")
local tty = require("md-render.tty")
local original_begin_batch = img.begin_batch
local original_flush_batch = img.flush_batch
local original_clear_all = img.clear_all
local original_delete_image = img.delete_image
local original_delete_images = img.delete_images
local original_put_image = img.put_image
local original_get_tty_path = tty.get_tty_path
local CHUNK = 4096
local next_image_id = 800000
local direct_images = {}
local batch_depth = 0
local after_flush = {}
tty.get_tty_path = function()
if vim.env.SSH_TTY and vim.fn.filereadable(vim.env.SSH_TTY) == 1 then
return vim.env.SSH_TTY
end
return original_get_tty_path()
end
local function queue_terminal(data)
image_debug("queue_terminal bytes=" .. tostring(#data) .. " batch_depth=" .. tostring(batch_depth))
if batch_depth > 0 then
table.insert(after_flush, data)
else
vim.api.nvim_ui_send(data)
end
end
local function flush_direct_queue()
if batch_depth == 0 and #after_flush > 0 then
image_debug("flush_direct_queue chunks=" .. tostring(#after_flush))
for _, data in ipairs(after_flush) do
vim.api.nvim_ui_send(data)
end
after_flush = {}
end
end
img.begin_batch = function(...)
batch_depth = batch_depth + 1
return original_begin_batch(...)
end
img.flush_batch = function(...)
local result = original_flush_batch(...)
batch_depth = math.max(0, batch_depth - 1)
flush_direct_queue()
return result
end
local function next_direct_id()
next_image_id = next_image_id + 1
return next_image_id
end
local function read_png_base64(path)
local f = io.open(path, "rb")
if not f then
return nil
end
local data = f:read("*a")
f:close()
return vim.base64.encode(data)
end
local function send_chunked(header, payload)
image_debug("send_chunked header=" .. header .. " payload_len=" .. tostring(#payload))
local i = 1
local first = true
while i <= #payload do
local piece = payload:sub(i, i + CHUNK - 1)
i = i + CHUNK
local more = i <= #payload and 1 or 0
local hdr
if first then
hdr = string.format("%s,m=%d", header, more)
first = false
else
hdr = string.format("m=%d,q=2", more)
end
queue_terminal("\27_G" .. hdr .. ";" .. piece .. "\27\\")
end
end
local function crop_params_for(win, row, col, display_cols, display_rows, img_w, img_h)
if not vim.api.nvim_win_is_valid(win) then return nil end
local win_pos = vim.api.nvim_win_get_position(win)
local wininfo = vim.fn.getwininfo(win)[1]
if not wininfo then return nil end
local win_height = wininfo.height
local topline = wininfo.topline - 1
local leftcol = wininfo.leftcol or 0
local textoff = wininfo.textoff or 0
local img_end_row = row + display_rows - 1
if img_end_row < topline or row >= topline + win_height then return nil end
local visual_row = row - topline
local border_left_width = 0
local border_top_height = 0
local ok_cfg, win_cfg = pcall(vim.api.nvim_win_get_config, win)
if ok_cfg and win_cfg.border then
local border = win_cfg.border
if type(border) == "table" then
local left = border[8]
if type(left) == "table" then left = left[1] end
if left and left ~= "" then
border_left_width = vim.api.nvim_strwidth(left)
end
local top = border[2]
if type(top) == "table" then top = top[1] end
if top and top ~= "" then
border_top_height = 1
end
elseif border ~= "none" and border ~= "" then
border_left_width = vim.api.nvim_strwidth("|")
border_top_height = 1
end
end
local visual_col = col - leftcol
local visible_text_cols = vim.api.nvim_win_get_width(win) - textoff
local img_end_col = col + display_cols - 1
if img_end_col < leftcol or col >= leftcol + visible_text_cols then return nil end
local screen_col = win_pos[2] + visual_col + border_left_width + textoff + 1
local src_x, src_y, src_w, src_h
if visual_col < 0 then
local hidden_cols = -visual_col
if img_w then
src_x = math.floor(img_w * hidden_cols / display_cols)
src_w = img_w - src_x
end
display_cols = display_cols - hidden_cols
visual_col = 0
screen_col = win_pos[2] + border_left_width + textoff + 1
end
if visual_row < 0 then
local hidden_rows = -visual_row
if img_h then
src_y = math.floor(img_h * hidden_rows / display_rows)
src_h = img_h - src_y
end
display_rows = display_rows - hidden_rows
visual_row = 0
end
local winbar_height = 0
local ok_wb, wb = pcall(function() return vim.wo[win].winbar end)
if ok_wb and wb and wb ~= "" then
winbar_height = 1
end
local screen_row = wininfo.winrow + visual_row + border_top_height + winbar_height
local visible_rows = win_height - visual_row
if visible_rows <= 0 then return nil end
if display_rows > visible_rows and img_h then
local remaining_h = src_h or img_h
src_h = math.floor(remaining_h * visible_rows / display_rows)
display_rows = visible_rows
end
local visible_cols = visible_text_cols - visual_col
if visible_cols <= 0 then return nil end
if display_cols > visible_cols and img_w then
local remaining_w = src_w or img_w
src_w = math.floor(remaining_w * visible_cols / display_cols)
display_cols = visible_cols
end
local crop_params = ""
if (src_x or src_y or src_w or src_h) and img_w and img_h then
crop_params = string.format(
",x=%d,y=%d,w=%d,h=%d",
src_x or 0,
src_y or 0,
src_w or img_w,
src_h or img_h
)
end
return {
screen_col = screen_col,
screen_row = screen_row,
display_cols = display_cols,
display_rows = display_rows,
crop_params = crop_params,
}
end
local function transmit_direct(path, callback)
image_debug("transmit_direct path=" .. tostring(path))
local payload = read_png_base64(path)
if not payload then
image_debug("transmit_direct no payload")
callback(nil)
return
end
local id = next_direct_id()
local img_w, img_h = img.image_dimensions(path)
image_debug("transmit_direct id=" .. tostring(id) .. " dims=" .. tostring(img_w) .. "x" .. tostring(img_h))
direct_images[id] = {
path = path,
payload = payload,
width = img_w,
height = img_h,
}
send_chunked(
string.format("a=t,f=100,t=d,i=%d,q=2", id),
payload
)
callback(id, img_w, img_h)
end
-- Herdr re-encodes every visible placement as base64 RGBA of the FULL
-- source image (bytes ≈ w*h*4*4/3, ~16x the PNG size) when forwarding
-- frames to its client, and drops ALL graphics for any frame whose
-- total exceeds 32MiB ("dropping oversized graphics payload" in
-- herdr-server.log). A scale-4 mermaid PNG alone is ~60MB on that
-- wire, so nothing paints. Cap source pixels inside herdr panes; the
-- render view is ≤45 rows so the downscale is visually lossless.
local HERDR_MAX_PIXELS = 1500000
local function herdr_scaled_path(png_path, max_dim)
local mtime = vim.fn.getftime(png_path)
local key = png_path .. "|" .. tostring(mtime) .. "|" .. tostring(max_dim)
local dir = vim.fn.stdpath("cache") .. "/md-render/herdr-scaled"
vim.fn.mkdir(dir, "p")
return dir .. "/" .. vim.fn.sha256(key):sub(1, 16) .. ".png"
end
-- Downscale tool detection, same priority as md-render's own
-- find_convert_tool(): sips (macOS) → ffmpeg → magick (IM7) →
-- convert (IM6, Linux distro ImageMagick installs).
local _downscale_tool = nil
local _downscale_checked = false
local function find_downscale_tool()
if _downscale_checked then return _downscale_tool end
_downscale_checked = true
if vim.fn.has("mac") == 1 and vim.fn.executable("sips") == 1 then
_downscale_tool = "sips"
elseif vim.fn.executable("ffmpeg") == 1 then
_downscale_tool = "ffmpeg"
elseif vim.fn.executable("magick") == 1 then
_downscale_tool = "magick"
elseif vim.fn.executable("convert") == 1 then
_downscale_tool = "convert"
end
return _downscale_tool
end
local function build_downscale_cmd(tool, src, dst, max_dim)
local dim = tostring(max_dim)
if tool == "sips" then
return { "sips", "-Z", dim, src, "--out", dst }
elseif tool == "ffmpeg" then
return {
"ffmpeg", "-y", "-i", src,
"-vframes", "1",
"-vf", "scale='min(" .. dim .. ",iw)':'min(" .. dim .. ",ih)':force_original_aspect_ratio=decrease",
dst,
}
else -- magick / convert share the same argument shape
return { tool, src, "-resize", dim .. "x" .. dim .. ">", dst }
end
end
local function downscale_for_herdr(png_path, callback)
if vim.env.HERDR_ENV ~= "1" then
callback(png_path)
return
end
local w, h = img.image_dimensions(png_path)
if not w or not h or w * h <= HERDR_MAX_PIXELS then
callback(png_path)
return
end
local scale = math.sqrt(HERDR_MAX_PIXELS / (w * h))
local max_dim = math.floor(math.max(w, h) * scale)
local out = herdr_scaled_path(png_path, max_dim)
if vim.fn.filereadable(out) == 1 then
image_debug("downscale_for_herdr cached " .. out)
callback(out)
return
end
-- If no tool is available or it fails, fall back to the original
-- (which herdr will drop, same as before this patch).
local tool = find_downscale_tool()
if not tool then
image_debug("downscale_for_herdr no downscale tool")
callback(png_path)
return
end
vim.system(
build_downscale_cmd(tool, png_path, out, max_dim),
{ text = true, timeout = 15000 },
function()
vim.schedule(function()
if vim.fn.filereadable(out) == 1 then
image_debug("downscale_for_herdr " .. tool .. " " .. w .. "x" .. h .. " -> max_dim=" .. max_dim)
callback(out)
else
image_debug("downscale_for_herdr " .. tool .. " failed")
callback(png_path)
end
end)
end
)
end
img.transmit_image_async = function(path, callback)
image_debug("transmit_image_async path=" .. tostring(path))
if not img.supports_kitty() then
image_debug("transmit_image_async no kitty support")
callback(nil)
return
end
img.ensure_png_async(path, function(png_path)
if not png_path then
image_debug("transmit_image_async no png_path")
callback(nil)
return
end
image_debug("transmit_image_async png_path=" .. tostring(png_path))
downscale_for_herdr(png_path, function(final_path)
transmit_direct(final_path, callback)
end)
end)
end
img.put_image = function(image_id, win, row, col, display_cols, display_rows, anim_path, img_w, img_h)
image_debug(
"put_image id=" .. tostring(image_id)
.. " row=" .. tostring(row)
.. " col=" .. tostring(col)
.. " cols=" .. tostring(display_cols)
.. " rows=" .. tostring(display_rows)
.. " anim=" .. tostring(anim_path)
)
local direct = direct_images[image_id]
if not direct or anim_path then
image_debug("put_image fallback direct=" .. tostring(direct ~= nil) .. " anim=" .. tostring(anim_path))
return original_put_image(image_id, win, row, col, display_cols, display_rows, anim_path, img_w, img_h)
end
if not img.supports_kitty() then return end
local placement = crop_params_for(
win,
row,
col,
display_cols,
display_rows,
img_w or direct.width,
img_h or direct.height
)
if not placement then
image_debug("put_image no placement")
return
end
image_debug(
"put_image placement screen="
.. tostring(placement.screen_row)
.. ","
.. tostring(placement.screen_col)
.. " cells="
.. tostring(placement.display_cols)
.. "x"
.. tostring(placement.display_rows)
.. " crop="
.. tostring(placement.crop_params)
)
queue_terminal("\27[s")
queue_terminal(string.format("\27[%d;%dH", placement.screen_row, placement.screen_col))
send_chunked(
string.format(
"a=T,f=100,t=d,i=%d,c=%d,r=%d%s,C=1,q=2",
image_id,
placement.display_cols,
placement.display_rows,
placement.crop_params
),
direct.payload
)
queue_terminal("\27[u")
end
img.clear_all = function(...)
direct_images = {}
return original_clear_all(...)
end
img.delete_image = function(image_id, ...)
direct_images[image_id] = nil
return original_delete_image(image_id, ...)
end
img.delete_images = function(image_ids, ...)
for _, image_id in ipairs(image_ids or {}) do
direct_images[image_id] = nil
end
return original_delete_images(image_ids, ...)
end
end
local function mermaid_sources(buf)
local sources = {}
local in_mermaid = false
local block = {}
for _, line in ipairs(vim.api.nvim_buf_get_lines(buf, 0, -1, false)) do
if not in_mermaid then
if line:match("^%s*```%s*mermaid%s*$") then
in_mermaid = true
block = {}
end
elseif line:match("^%s*```%s*$") then
if #block > 0 then
table.insert(sources, table.concat(block, "\n"))
end
in_mermaid = false
block = {}
else
table.insert(block, line)
end
end
return sources
end
local function prime_mermaid_cache(buf)
if not vim.api.nvim_buf_is_valid(buf) then return end
local ok, image = pcall(require, "md-render.image")
if not ok or not image.has_mmdc() then return end
for _, source in ipairs(mermaid_sources(buf)) do
if not image.get_mermaid_cached(source) then
image.render_mermaid(source)
end
end
end
local function ensure_md_render_auto(buf)
if not vim.api.nvim_buf_is_valid(buf) then return end
if vim.bo[buf].filetype ~= "markdown" then return end
if not vim.b[buf].md_render_auto then
local cache_group = vim.api.nvim_create_augroup("MdRenderMermaidCache" .. buf, { clear = true })
vim.api.nvim_create_autocmd({ "InsertLeave", "BufWritePost" }, {
group = cache_group,
buffer = buf,
callback = function()
prime_mermaid_cache(buf)
end,
desc = "Prime md-render Mermaid cache before redraw",
})
end
prime_mermaid_cache(buf)
if vim.b[buf].md_render_auto then return end
local ok, md = pcall(require, "md-render")
if ok then
local win = vim.api.nvim_get_current_win()
local ok_state, state = pcall(vim.api.nvim_win_get_var, win, "md_render_state")
if ok_state and type(state) == "table" and state.source_buf ~= buf then
pcall(vim.api.nvim_win_del_var, win, "md_render_state")
end
pcall(md.preview.auto_on, { max_width = 140 })
end
end
local group = vim.api.nvim_create_augroup("MdRenderMarkdownAuto", { clear = true })
vim.api.nvim_create_autocmd("FileType", {
group = group,
pattern = { "markdown", "*.md", "*.markdown" },
callback = function(ev)
-- Defer so the FileType pass that loads md-render completes first.
vim.schedule(function()
if vim.api.nvim_get_current_buf() == ev.buf then
ensure_md_render_auto(ev.buf)
end
end)
end,
desc = "Enable md-render auto mode for markdown buffers",
})
for _, buf in ipairs(vim.api.nvim_list_bufs()) do
vim.schedule(function()
ensure_md_render_auto(buf)
end)
end
end,
},
-- Disable the previous attempts so they don't fight md-render for the same
-- terminal cells (the "1 frame then gone" / "behind the text" symptoms).
{ "3rd/image.nvim", enabled = false },
{ "3rd/diagram.nvim", enabled = false },
{
"folke/snacks.nvim",
opts = function(_, opts)
opts.image = vim.tbl_deep_extend("force", opts.image or {}, {
enabled = false,
})
return opts
end,
},
}
@@ -0,0 +1,17 @@
-- Override LazyVim's indent-blankline for VS Code-like indent guides
return {
{
"lukas-reineke/indent-blankline.nvim",
main = "ibl", -- v3 module name
opts = {
indent = {
char = "│",
},
scope = {
enabled = true,
show_start = false,
show_end = false,
},
},
},
}
@@ -0,0 +1,12 @@
return {
{
"mfussenegger/nvim-lint",
optional = true,
opts = {
linters_by_ft = {
markdown = {}, -- no linters for .md
["markdown.mdx"] = {}, -- no linters for .mdx
},
},
},
}
@@ -0,0 +1,27 @@
return {
-- Disable markdown diagnostics/linting but keep LSP for formatting
{
"neovim/nvim-lspconfig",
opts = {
servers = {
marksman = {
-- Keep marksman for markdown LSP features like formatting
handlers = {
-- Disable diagnostics from marksman
["textDocument/publishDiagnostics"] = function() end,
},
},
},
},
},
-- Also disable any lint-related diagnostics for markdown files
{
"mfussenegger/nvim-lint",
optional = true,
opts = {
linters_by_ft = {
markdown = {},
},
},
},
}
@@ -0,0 +1,52 @@
-- VS Code-style minimap using neominimap.nvim
return {
{
"Isrothy/neominimap.nvim",
version = "v3.*.*",
lazy = false,
keys = {
{ "<leader>um", "<cmd>Neominimap Toggle<cr>", desc = "Toggle minimap" },
{ "<leader>uf", "<cmd>Neominimap Focus<cr>", desc = "Focus minimap" },
},
init = function()
vim.g.neominimap = {
auto_enable = true,
-- Higher resolution (lower = denser)
x_multiplier = 2,
y_multiplier = 1,
layout = "float",
float = {
minimap_width = 8,
window_border = "none",
},
-- Enable mouse clicks
click = {
enabled = true,
auto_switch_focus = true,
},
-- Annotations
diagnostic = {
enabled = true,
mode = "line", -- Highlight full line
},
git = {
enabled = true,
mode = "line", -- Full line highlight for git changes
},
search = {
enabled = true,
mode = "line",
},
treesitter = {
enabled = true,
},
exclude_filetypes = { "help", "neo-tree", "lazy", "mason", "dashboard" },
}
end,
},
}
@@ -0,0 +1,115 @@
-- Inline LLM (ghost-text) completion tuned for writing markdown/prose.
-- Reuses ZAI_API_KEY from the environment (same key the :AI command uses).
-- Suggestions are virtual text, auto-triggered behind a debounce delay.
local PROSE_FT = {
markdown = true,
["md-render"] = true,
text = true,
rst = true,
tex = true,
gitcommit = true,
mail = true,
}
return {
"milanglacier/minuet-ai.nvim",
dependencies = { "nvim-lua/plenary.nvim" },
-- Load eagerly: minuet only sets its buffer-local auto-trigger flag from a
-- FileType autocmd registered in setup(). Lazy-loading on InsertEnter would
-- register that autocmd *after* the current buffer's FileType already fired,
-- so virtualtext would never auto-trigger on the buffer you're editing.
lazy = false,
opts = {
provider = "openai_compatible",
-- One natural continuation, not three alternatives — prose has a single
-- "next sentence", and fewer candidates means less to read past.
n_completions = 1,
-- Give the model more of the text *before* the cursor (default 0.75).
context_ratio = 0.85,
-- Cap how much surrounding text is sent. Smaller = lower, steadier latency
-- (the full default of 16000 chars is overkill for prose continuation and
-- inflates time-to-first-token). 4000 chars ≈ plenty of preceding context.
context_window = 4000,
-- Delay knobs: wait for an idle pause, and rate-limit requests. Groq is fast
-- and deterministic, so we can afford a snappier debounce than the cloud
-- defaults; throttle stays moderate to respect the free-tier rate limit.
throttle = 800, -- min ms between requests
debounce = 400, -- ms of idle typing before a request fires
provider_options = {
openai_compatible = {
api_key = "GROQ_API_KEY", -- env var NAME; minuet reads it at runtime
name = "Groq",
-- Groq's LPU inference gives ~80-100ms time-to-first-token with very low
-- variance — that consistency is the whole reason for moving off Z.AI's
-- hit-or-miss coding endpoint. OpenAI-compatible, so the schema is unchanged.
end_point = "https://api.groq.com/openai/v1/chat/completions",
-- llama-3.3-70b is Groq's flagship general model: strong prose, still fast
-- on their hardware. If you ever want the absolute lowest latency, swap to
-- "llama-3.1-8b-instant" (faster, slightly weaker prose).
model = "llama-3.3-70b-versatile",
-- Swap minuet's "code completion engine" system prompt for a prose one
-- in writing filetypes; keep the default (string) for code. Guidelines
-- and few-shots are left at defaults so minuet's <endCompletion> parsing
-- still works.
system = {
prompt = function()
if PROSE_FT[vim.bo.filetype] then
return [[
You are a prose writing assistant embedded in a text editor. Continue the
author's text at the <cursorPosition> marker naturally and fluently, matching
their voice, tone, and sentence rhythm.
- Output ONLY the continuation text — no preamble, no explanation.
- Never wrap the output in markdown code fences or backticks.
- Do not add headings, bullets, or numbering unless the surrounding text
already uses them.
- Keep it to at most 1-3 sentences and stop at a natural boundary.]]
end
return require("minuet.config").default_system_prefix_first.prompt
end,
},
optional = {
-- Short, focused completions. (No `thinking` field here — that was a
-- Z.AI-specific param; Groq's Llama models 400 on unknown body fields.)
max_tokens = 96,
temperature = 0.3,
},
},
},
virtualtext = {
auto_trigger_ft = { "*" },
-- Alt keymaps are kept as a secondary path, but the primary accept key is
-- <Tab>, wired in config() below (Alt+* doesn't fire in Ghostty without
-- macos-option-as-alt, and Tab is the natural "accept" key for prose).
keymap = {
accept = "<A-y>", -- accept full suggestion
accept_line = "<A-l>", -- accept one line
prev = "<A-[>",
next = "<A-]>",
dismiss = "<A-e>",
},
},
},
config = function(_, opts)
require("minuet").setup(opts)
-- Smart <Tab>: accept a visible minuet ghost-text suggestion; otherwise fall
-- back to the exact super-tab behavior we had before (jump an active snippet,
-- else insert a literal Tab). Blink's menu navigates with <C-n>/<C-p>, not
-- Tab, so this doesn't fight the completion popup.
local vt = require("minuet.virtualtext").action
vim.keymap.set("i", "<Tab>", function()
if vt.is_visible() then
vt.accept()
elseif vim.snippet.active({ direction = 1 }) then
vim.snippet.jump(1)
else
vim.api.nvim_feedkeys(
vim.api.nvim_replace_termcodes("<Tab>", true, false, true),
"n",
false
)
end
end, { desc = "minuet: accept suggestion, else snippet jump / Tab" })
end,
}
@@ -0,0 +1,3 @@
-- Navigation enhancement plugins
-- (neoscroll removed due to conflicts with smear-cursor)
return {}
@@ -0,0 +1,14 @@
return {
"nvim-neo-tree/neo-tree.nvim",
opts = function(_, opts)
opts.filesystem = opts.filesystem or {}
opts.filesystem.filtered_items = opts.filesystem.filtered_items or {}
-- make sure .log files are visible
opts.filesystem.filtered_items.hide_gitignored = false
opts.filesystem.filtered_items.hide_dotfiles = false
opts.filesystem.filtered_items.always_show = opts.filesystem.filtered_items.always_show or {}
table.insert(opts.filesystem.filtered_items.always_show, "*.log")
end,
}
@@ -0,0 +1,4 @@
-- Disable noice.nvim (causing treesitter errors)
return {
{ "folke/noice.nvim", enabled = false },
}
@@ -0,0 +1,12 @@
return {
"nvim-tree/nvim-tree.lua",
dependencies = {
"nvim-tree/nvim-web-devicons",
},
keys = {
{ "<leader>e", "<cmd>NvimTreeToggle<cr>", desc = "Toggle file explorer" },
},
config = function()
require("nvim-tree").setup({})
end,
}
@@ -0,0 +1,35 @@
return {
"nvim-telescope/telescope.nvim",
opts = {
defaults = {
-- Don't respect .gitignore - show logs and temp files
file_ignore_patterns = {
"node_modules/",
"__pycache__/",
".git/",
".pytest_cache/",
"%.pyc$",
"%.pyo$",
},
-- Make sure to not use git_files by default
vimgrep_arguments = {
"rg",
"--color=never",
"--no-heading",
"--with-filename",
"--line-number",
"--column",
"--smart-case",
-- Don't respect gitignore
"--no-ignore",
},
},
pickers = {
find_files = {
-- Show hidden files and don't respect gitignore
hidden = true,
no_ignore = true,
},
},
},
}
@@ -0,0 +1,14 @@
return {
{
"ThePrimeagen/refactoring.nvim",
dependencies = {
"nvim-lua/plenary.nvim",
"nvim-treesitter/nvim-treesitter",
"lewis6991/async.nvim",
},
ft = { "lua", "python", "javascript", "typescript", "go", "c", "cpp", "java", "php", "ruby" },
config = function()
require("refactoring").setup()
end,
},
}
@@ -0,0 +1,58 @@
-- Enhanced search and replace functionality
return {
-- Better search highlighting with match counts
-- Shows "X/Y" match indicator and improves n/N navigation
{
"kevinhwang91/nvim-hlslens",
event = "VeryLazy",
enabled = true, -- Set to false to disable
keys = {
{ "n", [[<Cmd>execute('normal! ' . v:count1 . 'n')<CR><Cmd>lua require('hlslens').start()<CR>]], desc = "Next search result" },
{ "N", [[<Cmd>execute('normal! ' . v:count1 . 'N')<CR><Cmd>lua require('hlslens').start()<CR>]], desc = "Previous search result" },
{ "*", [[*<Cmd>lua require('hlslens').start()<CR>]], desc = "Search word under cursor" },
{ "#", [[#<Cmd>lua require('hlslens').start()<CR>]], desc = "Search word under cursor (backward)" },
{ "g*", [[g*<Cmd>lua require('hlslens').start()<CR>]], desc = "Search word under cursor (no boundary)" },
{ "g#", [[g#<Cmd>lua require('hlslens').start()<CR>]], desc = "Search word under cursor (no boundary, backward)" },
},
opts = {
calm_down = true,
nearest_only = true,
nearest_float_when = "always",
},
},
-- Advanced search and replace with multiple strategies
-- Provides visual search/replace interface
-- Use :SearchReplaceSingleBuffer, :SearchReplaceMultiBuffer
{
"roobert/search-replace.nvim",
cmd = {
"SearchReplaceSingleBuffer",
"SearchReplaceMultiBuffer",
"SearchReplaceWithinVisualSelection",
"SearchReplaceWithinVisualSelectionCWord",
},
enabled = true, -- Set to false to disable
opts = {
default_replace_single_buffer_options = "gcI",
default_replace_multi_buffer_options = "egcI",
},
keys = {
-- Single buffer replacements
{ "<leader>rs", "<CMD>SearchReplaceSingleBufferSelections<CR>", desc = "Search/Replace Selections", mode = "v" },
{ "<leader>ro", "<CMD>SearchReplaceSingleBufferOpen<CR>", desc = "Search/Replace Open" },
{ "<leader>rw", "<CMD>SearchReplaceSingleBufferCWord<CR>", desc = "Search/Replace Word" },
{ "<leader>rW", "<CMD>SearchReplaceSingleBufferCWORD<CR>", desc = "Search/Replace WORD" },
{ "<leader>re", "<CMD>SearchReplaceSingleBufferCExpr<CR>", desc = "Search/Replace Expr" },
{ "<leader>rf", "<CMD>SearchReplaceSingleBufferCFile<CR>", desc = "Search/Replace File" },
-- Multi buffer replacements
{ "<leader>rbs", "<CMD>SearchReplaceMultiBufferSelections<CR>", desc = "Search/Replace Selections (Multi)", mode = "v" },
{ "<leader>rbo", "<CMD>SearchReplaceMultiBufferOpen<CR>", desc = "Search/Replace Open (Multi)" },
{ "<leader>rbw", "<CMD>SearchReplaceMultiBufferCWord<CR>", desc = "Search/Replace Word (Multi)" },
{ "<leader>rbW", "<CMD>SearchReplaceMultiBufferCWORD<CR>", desc = "Search/Replace WORD (Multi)" },
{ "<leader>rbe", "<CMD>SearchReplaceMultiBufferCExpr<CR>", desc = "Search/Replace Expr (Multi)" },
{ "<leader>rbf", "<CMD>SearchReplaceMultiBufferCFile<CR>", desc = "Search/Replace File (Multi)" },
},
},
}
@@ -0,0 +1,9 @@
return {
"sphamba/smear-cursor.nvim",
opts = {
smear_between_buffers = true,
smear_between_neighbor_lines = true,
scroll_buffer_space = true,
smear_insert_mode = true,
},
}
@@ -0,0 +1,273 @@
-- Colorscheme/theme plugins
return {
-- Catppuccin
{
"catppuccin/nvim",
name = "catppuccin",
lazy = false,
priority = 1000,
},
-- Tokyo Night
{
"folke/tokyonight.nvim",
lazy = false,
priority = 1000,
},
-- Kanagawa
{
"rebelot/kanagawa.nvim",
lazy = false,
priority = 1000,
},
-- Rose Pine
{
"rose-pine/neovim",
name = "rose-pine",
lazy = false,
priority = 1000,
},
-- Nightfox family (Nightfox, Nordfox, Dawnfox, Duskfox, Carbonfox, Terafox)
{
"EdenEast/nightfox.nvim",
lazy = false,
priority = 1000,
},
-- Gruvbox
{
"ellisonleao/gruvbox.nvim",
lazy = false,
priority = 1000,
},
-- Gruvbox Material
{
"sainnhe/gruvbox-material",
lazy = false,
priority = 1000,
},
-- Nord
{
"shaunsingh/nord.nvim",
lazy = false,
priority = 1000,
},
-- Nordic
{
"AlexvZyl/nordic.nvim",
lazy = false,
priority = 1000,
},
-- Everforest
{
"neanias/everforest-nvim",
lazy = false,
priority = 1000,
},
-- Dracula
{
"Mofiqul/dracula.nvim",
lazy = false,
priority = 1000,
},
-- OneDark
{
"navarasu/onedark.nvim",
lazy = false,
priority = 1000,
},
-- One Dark Pro
{
"olimorris/onedarkpro.nvim",
lazy = false,
priority = 1000,
},
-- Solarized
{
"maxmx03/solarized.nvim",
lazy = false,
priority = 1000,
},
-- Monokai Pro
{
"loctvl842/monokai-pro.nvim",
lazy = false,
priority = 1000,
},
-- Cyberdream
{
"scottmckendry/cyberdream.nvim",
lazy = false,
priority = 1000,
},
-- Oxocarbon
{
"nyoom-engineering/oxocarbon.nvim",
lazy = false,
priority = 1000,
},
-- Melange
{
"savq/melange-nvim",
lazy = false,
priority = 1000,
},
-- Nightfly
{
"bluz71/vim-nightfly-colors",
name = "nightfly",
lazy = false,
priority = 1000,
},
-- Moonfly
{
"bluz71/vim-moonfly-colors",
name = "moonfly",
lazy = false,
priority = 1000,
},
-- Sonokai
{
"sainnhe/sonokai",
lazy = false,
priority = 1000,
},
-- Edge
{
"sainnhe/edge",
lazy = false,
priority = 1000,
},
-- Ayu
{
"Shatur/neovim-ayu",
lazy = false,
priority = 1000,
},
-- Material
{
"marko-cerovac/material.nvim",
lazy = false,
priority = 1000,
},
-- Palenight
{
"drewtempelmeyer/palenight.vim",
lazy = false,
priority = 1000,
},
-- GitHub theme
{
"projekt0n/github-nvim-theme",
lazy = false,
priority = 1000,
},
-- Tokyodark
{
"tiagovla/tokyodark.nvim",
lazy = false,
priority = 1000,
},
-- Darkplus (VS Code dark theme)
{
"lunarvim/darkplus.nvim",
lazy = false,
priority = 1000,
},
-- Poimandres
{
"olivercederborg/poimandres.nvim",
lazy = false,
priority = 1000,
},
-- Flow
{
"0xstepit/flow.nvim",
lazy = false,
priority = 1000,
},
-- Modus themes
{
"miikanissi/modus-themes.nvim",
lazy = false,
priority = 1000,
},
-- Zenbones
{
"mcchrish/zenbones.nvim",
dependencies = "rktjmp/lush.nvim",
lazy = false,
priority = 1000,
},
-- Apprentice
{
"romainl/Apprentice",
lazy = false,
priority = 1000,
},
-- Jellybeans
{
"nanotech/jellybeans.vim",
lazy = false,
priority = 1000,
},
-- Tender
{
"jacoborus/tender.vim",
lazy = false,
priority = 1000,
},
-- Horizon
{
"ntk148v/vim-horizon",
lazy = false,
priority = 1000,
},
-- Moonlight
{
"shaunsingh/moonlight.nvim",
lazy = false,
priority = 1000,
},
-- Lackluster
{
"slugbyte/lackluster.nvim",
lazy = false,
priority = 1000,
},
}
@@ -0,0 +1,162 @@
-- UI enhancement plugins for better visual experience
return {
-- Render markdown with better formatting in buffers
{
"MeanderingProgrammer/render-markdown.nvim",
ft = "markdown",
enabled = true,
dependencies = { "nvim-treesitter/nvim-treesitter", "nvim-tree/nvim-web-devicons" },
opts = {
render_modes = { "n", "i", "c", "t" },
heading = {
enabled = true,
sign = true,
icons = { "󰲡 ", "󰲣 ", "󰲥 ", "󰲧 ", "󰲩 ", "󰲫 " },
},
code = {
enabled = true,
sign = true,
style = "normal",
width = "block",
},
bullet = {
enabled = true,
icons = { "●", "○", "◆", "◇" },
},
pipe_table = {
cell = "trimmed",
},
},
},
-- Highlight function arguments with different colors
-- Makes it easier to distinguish parameters
{
"m-demare/hlargs.nvim",
event = "VeryLazy",
enabled = true, -- Set to false to disable
dependencies = { "nvim-treesitter/nvim-treesitter" },
opts = {
color = "#ef9062",
highlight = {},
excluded_filetypes = {},
paint_arg_declarations = true,
paint_arg_usages = true,
performance = {
parse_delay = 1,
slow_parse_delay = 50,
max_iterations = 400,
max_concurrent_partial_parses = 30,
},
},
},
-- Dim inactive portions of code
-- Great for focusing on specific functions/blocks
-- Toggle with :Twilight
{
"folke/twilight.nvim",
cmd = { "Twilight", "TwilightEnable", "TwilightDisable" },
enabled = true, -- Set to false to disable
opts = {
dimming = {
alpha = 0.25,
color = { "Normal", "#ffffff" },
term_bg = "#000000",
inactive = false,
},
context = 10,
treesitter = true,
expand = {
"function",
"method",
"table",
"if_statement",
},
},
keys = {
{ "<leader>ut", "<cmd>Twilight<cr>", desc = "Toggle Twilight" },
},
},
-- Theme switcher with live preview
-- Browse and switch between colorschemes with instant preview
-- Open with :Themery
{
"zaldih/themery.nvim",
lazy = false, -- Load on startup to apply saved theme
priority = 1000, -- Load before other plugins
enabled = true,
keys = {
{ "<leader>uT", "<cmd>Themery<cr>", desc = "Theme Picker" },
},
config = function()
require("themery").setup({
themes = {
-- Catppuccin variants
{ name = "Catppuccin Latte", colorscheme = "catppuccin-latte" },
{ name = "Catppuccin Frappe", colorscheme = "catppuccin-frappe" },
{ name = "Catppuccin Macchiato", colorscheme = "catppuccin-macchiato" },
{ name = "Catppuccin Mocha", colorscheme = "catppuccin-mocha" },
-- Tokyo Night variants
{ name = "Tokyo Night", colorscheme = "tokyonight" },
{ name = "Tokyo Night - Night", colorscheme = "tokyonight-night" },
{ name = "Tokyo Night - Storm", colorscheme = "tokyonight-storm" },
{ name = "Tokyo Night - Day", colorscheme = "tokyonight-day" },
{ name = "Tokyo Night - Moon", colorscheme = "tokyonight-moon" },
-- Kanagawa variants
{ name = "Kanagawa", colorscheme = "kanagawa" },
{ name = "Kanagawa Wave", colorscheme = "kanagawa-wave" },
{ name = "Kanagawa Dragon", colorscheme = "kanagawa-dragon" },
{ name = "Kanagawa Lotus", colorscheme = "kanagawa-lotus" },
-- Rose Pine variants
{ name = "Rose Pine", colorscheme = "rose-pine" },
{ name = "Rose Pine Moon", colorscheme = "rose-pine-moon" },
{ name = "Rose Pine Dawn", colorscheme = "rose-pine-dawn" },
-- Nightfox variants
{ name = "Nightfox", colorscheme = "nightfox" },
{ name = "Nordfox", colorscheme = "nordfox" },
{ name = "Dawnfox", colorscheme = "dawnfox" },
{ name = "Duskfox", colorscheme = "duskfox" },
{ name = "Carbonfox", colorscheme = "carbonfox" },
{ name = "Terafox", colorscheme = "terafox" },
-- Gruvbox variants
{ name = "Gruvbox Dark", colorscheme = "gruvbox" },
{ name = "Gruvbox Material", colorscheme = "gruvbox-material" },
-- Popular themes that actually work
{ name = "Nord", colorscheme = "nord" },
{ name = "Everforest", colorscheme = "everforest" },
{ name = "Dracula", colorscheme = "dracula" },
{ name = "OneDark", colorscheme = "onedark" },
{ name = "Solarized", colorscheme = "solarized" },
{ name = "Monokai Pro", colorscheme = "monokai-pro" },
{ name = "Cyberdream", colorscheme = "cyberdream" },
{ name = "Oxocarbon", colorscheme = "oxocarbon" },
{ name = "Melange", colorscheme = "melange" },
{ name = "Nightfly", colorscheme = "nightfly" },
{ name = "Moonfly", colorscheme = "moonfly" },
{ name = "Sonokai", colorscheme = "sonokai" },
{ name = "Edge", colorscheme = "edge" },
{ name = "Ayu", colorscheme = "ayu" },
{ name = "Material", colorscheme = "material" },
{ name = "Palenight", colorscheme = "palenight" },
{ name = "GitHub Dark", colorscheme = "github_dark" },
{ name = "GitHub Dark Dimmed", colorscheme = "github_dark_dimmed" },
{ name = "GitHub Light", colorscheme = "github_light" },
{ name = "Tokyodark", colorscheme = "tokyodark" },
{ name = "Darkplus", colorscheme = "darkplus" },
{ name = "Poimandres", colorscheme = "poimandres" },
{ name = "Modus Vivendi", colorscheme = "modus" },
{ name = "Zenbones", colorscheme = "zenbones" },
{ name = "Nordic", colorscheme = "nordic" },
{ name = "Jellybeans", colorscheme = "jellybeans" },
{ name = "Tender", colorscheme = "tender" },
{ name = "Horizon", colorscheme = "horizon" },
{ name = "Moonlight", colorscheme = "moonlight" },
{ name = "Lackluster", colorscheme = "lackluster" },
},
livePreview = true, -- Apply theme while navigating
})
end,
},
}
@@ -0,0 +1,14 @@
-- Yanky is enabled via LazyVim's `coding.yanky` extra. Its default
-- `system_clipboard.sync_with_ring = true` reads the `+` register on every yank.
-- Over SSH/herdr the clipboard provider is copy-only OSC 52 (see
-- config/options.lua), so a ring<->clipboard sync is pointless and historically
-- threw when OSC 52 reads were attempted. Disable it on remote sessions only;
-- keep full sync locally where wl-copy/pbcopy make reads cheap and useful.
return {
"gbprod/yanky.nvim",
opts = {
system_clipboard = {
sync_with_ring = vim.env.SSH_CONNECTION == nil,
},
},
}
+3
View File
@@ -0,0 +1,3 @@
indent_type = "Spaces"
indent_width = 2
column_width = 120
+1
View File
@@ -0,0 +1 @@
prefix={{ .chezmoi.homeDir }}/.npm-global
+1739
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
# Guarded. Unguarded this printed "no such file or directory" on every zsh
# start on any machine without rustup -- which is every throwaway VM.
[ -r "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
+192
View File
@@ -0,0 +1,192 @@
# ~/.zshrc
#
# A PLAIN FILE. Not a chezmoi template, deliberately.
#
# Templating this file is what broke sync-back: `chezmoi re-add` silently does
# nothing on a .tmpl, so every edit made here had to be hand-copied into the
# source or lost. Platform differences are handled at runtime below, by zsh,
# which it is perfectly capable of doing.
#
# NO SECRETS LIVE HERE. Keys come from ~/.config/zsh/secrets.zsh, which is
# generated from Bitwarden Secrets Manager, mode 600, and in no repository.
# If that file is absent this shell still works — you just have no API keys.
# ---------------------------------------------------------------- platform ---
# Set before anything else; everything below branches on these instead of on
# chezmoi template conditionals.
typeset -g _mac=0 _lin=0 _wsl=0 _ubuntu=0
[[ $OSTYPE == darwin* ]] && _mac=1
[[ $OSTYPE == linux* ]] && _lin=1
[[ -n $WSL_DISTRO_NAME || -r /proc/sys/fs/binfmt_misc/WSLInterop ]] && _wsl=1
(( _lin )) && [[ -r /etc/os-release ]] && \
grep -qi '^ID=ubuntu' /etc/os-release && _ubuntu=1
# ----------------------------------------------------------------- secrets ---
# One guarded source, and it is the only path by which a key enters this shell.
# Regenerate with `dotsecrets`. Never edit by hand, never commit.
[[ -r ${XDG_CONFIG_HOME:-$HOME/.config}/zsh/secrets.zsh ]] && \
source ${XDG_CONFIG_HOME:-$HOME/.config}/zsh/secrets.zsh
# The bws token itself is a file, not a secret in this file. It is what lets
# `bws` and `dotsecrets` run at all.
[[ -r $HOME/.config/bitwarden/bws-token ]] && \
export BWS_ACCESS_TOKEN="$(<$HOME/.config/bitwarden/bws-token)"
# --------------------------------------------------------- pre-prompt setup ---
# Anything that must be set before the p10k instant prompt goes here. Nothing
# in this section may write to stdout.
export ZSH_AI_PROVIDER="gemini"
export ZSH_AI_GEMINI_MODEL="gemini-2.5-flash"
# Endpoint, not a credential — the matching key comes from secrets.zsh.
export ZAI_BASE_URL="https://api.z.ai/api/anthropic"
(( _ubuntu )) && {
export GDK_BACKEND=wayland
export QT_QPA_PLATFORM=wayland
export CLUTTER_BACKEND=wayland
export SDL_VIDEODRIVER=wayland
}
# Enable Powerlevel10k instant prompt. Should stay close to the top of ~/.zshrc.
# Initialization code that may require console input (password prompts, [y/n]
# confirmations, etc.) must go above this block; everything else may go below.
if [[ -r "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh" ]]; then
source "${XDG_CACHE_HOME:-$HOME/.cache}/p10k-instant-prompt-${(%):-%n}.zsh"
fi
# -------------------------------------------------------------------- PATH ---
# `typeset -U` makes $path a unique-only array, so ordering is intentional and
# duplicates are impossible — which is what the eight scattered appends and the
# doubled ~/.local/bin used to produce.
typeset -U path PATH
_prepend() { [[ -d $1 ]] && path=("$1" $path) }
_append() { [[ -d $1 ]] && path=($path "$1") }
_prepend "$HOME/bin"
_prepend "$HOME/.local/bin"
_prepend "$HOME/.npm-global/bin"
_prepend "$HOME/code/speccy/bin"
_append /usr/local/bin
_append /usr/local/go/bin
export GOPATH="$HOME/go"
export GOMODCACHE="$GOPATH/pkg/mod"
_prepend "$GOPATH/bin"
_append "$HOME/.cache/lm-studio/bin"
(( _mac )) && {
_prepend /opt/homebrew/bin
_prepend /opt/homebrew/sbin
_prepend /opt/homebrew/opt/hcxtools/bin
[[ -n $BUN_INSTALL ]] && _prepend "$BUN_INSTALL/bin"
export TERMINFO=/Applications/Ghostty.app/Contents/Resources/terminfo
# Garmin Connect IQ SDK (tracks the SDK selected in current-sdk.cfg)
_ciq="$HOME/Library/Application Support/Garmin/ConnectIQ/current-sdk.cfg"
[[ -f $_ciq ]] && _append "$(<"$_ciq")bin"
unset _ciq
}
(( _lin )) && {
_append /usr/libexec/virtiofsd
_append "$HOME/code/vp9enc-rs/target/release"
}
(( _ubuntu )) && export LD_LIBRARY_PATH="/opt/Qt-6.7.3/lib:$LD_LIBRARY_PATH"
unfunction _prepend _append
# ------------------------------------------------------------------ limits ---
ulimit -n 65535
(( _mac )) && ulimit -u 2048 || ulimit -u 65535
# ------------------------------------------------------------------ oh-my-zsh -
export ZSH="$HOME/.oh-my-zsh"
export ZSH_CUSTOM="${ZSH_CUSTOM:-$ZSH/custom}"
ZSH_THEME="powerlevel10k/powerlevel10k"
plugins=(git zsh-autosuggestions sudo web-search dirhistory history)
(( _mac )) && plugins+=(docker)
# zsh-ai is loaded HERE and only here. It used to be added to this array on
# every platform AND separately sourced from /opt/homebrew on macOS, so it
# loaded twice on mac. The existence guard also stops the array referencing a
# plugin that was never installed.
[[ -z $ZSH_AI_DISABLE && -d $ZSH_CUSTOM/plugins/zsh-ai ]] && plugins+=(zsh-ai)
# Guarded. Unguarded, a clean bootstrap produces a broken login shell and
# nothing tells you why.
if [[ -r $ZSH/oh-my-zsh.sh ]]; then
source $ZSH/oh-my-zsh.sh
else
print -u2 "zshrc: oh-my-zsh missing at $ZSH — run dotup to install it"
fi
zle -N create_completion
bindkey '^B' create_completion
[[ -f ~/.p10k.zsh ]] && source ~/.p10k.zsh
# ----------------------------------------------------------------- aliases ---
(( _ubuntu )) && alias fd=fdfind
(( _mac )) && {
alias py="/opt/homebrew/opt/python@3.13/Frameworks/Python.framework/Versions/3.13/bin/python3"
alias tailscale="/Applications/Tailscale.app/Contents/MacOS/Tailscale"
}
alias umpv="$HOME/code/mpv/TOOLS/umpv"
(( _lin )) && {
alias lada-runner="LADA_RUNNER_CONCURRENCY=2 uv --directory $HOME/tools/lada-runner run lada-runner tui"
alias lada-cli="flatpak run --filesystem=host --command=lada-cli io.github.ladaapp.lada"
}
# Work Claude account — separate creds/history/projects, shared CLAUDE.md+rules+skills+agents
alias wclaude='CLAUDE_CONFIG_DIR=$HOME/.claude-work claude'
# A function, not an alias. As an alias the token was expanded into the alias
# body at definition time, so `alias` printed the key in full to anyone who ran
# it. A function defers expansion to call time.
glm() {
ANTHROPIC_BASE_URL="$ZAI_BASE_URL" ANTHROPIC_AUTH_TOKEN="$ZAI_API_KEY" \
claude --dangerously-skip-permissions \
--settings "$HOME/.claude/settings-glm.json" "$@"
}
_open() { (( _mac )) && open "$1" || xdg-open "$1" >/dev/null 2>&1 }
# ------------------------------------------------------------------- local ---
# Identity-bearing shell config -- the gitea aliases and their WAN/LAN hosts,
# work shortcuts, anything naming a host you own -- lives in the PRIVATE tier,
# which writes this file. It is absent on a public-only machine, and the guard
# makes that a silent no-op. `_mac` and `_open` above are still in scope here,
# so a moved alias needs no rewriting.
[[ -r ${XDG_CONFIG_HOME:-$HOME/.config}/zsh/local.zsh ]] && \
source ${XDG_CONFIG_HOME:-$HOME/.config}/zsh/local.zsh
# ---------------------------------------------------------------- homebrew ---
if (( _mac )); then
eval "$(/opt/homebrew/bin/brew shellenv 2>/dev/null || /usr/local/bin/brew shellenv)"
elif [[ -x /home/linuxbrew/.linuxbrew/bin/brew ]]; then
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
fi
# ---------------------------------------------------------------- bitwarden ---
# Interactive personal-vault unlock. Nothing automated depends on this; the
# unattended path is bws + the token file above.
if command -v bw >/dev/null 2>&1; then
bwunlock() {
export BW_SESSION="$(bw unlock --raw)"
print "Bitwarden unlocked. Session expires based on your timeout setting."
}
fi
export ZSH_CODEX_PYTHON="$HOME/.local/venvs/codex/bin/python"
# iris was removed here. Do NOT add `eval "$(iris init zsh)"` — it emits
# `exec iris`, which replaces every interactive shell, and combined with the
# p10k instant prompt above (upstream #55) logins fork-cascade until SSH
# appears frozen. See the sysjournal note before reintroducing it.
unset _mac _lin _wsl _ubuntu
+1
View File
@@ -0,0 +1 @@
.tmux/.tmux.conf