Compare commits
1 Commits
main
...
521d9b73a3
| Author | SHA1 | Date | |
|---|---|---|---|
|
521d9b73a3
|
@@ -1,2 +0,0 @@
|
||||
# typos
|
||||
f148ab1559cbb4e0e46e05f8d151f74f64adb359
|
||||
114
.gitignore
vendored
114
.gitignore
vendored
@@ -4,17 +4,24 @@
|
||||
# allowed misc files
|
||||
!.gitmodules
|
||||
!.gitignore
|
||||
!.git-blame-ignore-revs
|
||||
!README.md
|
||||
|
||||
# allow some stuff to be pushed
|
||||
!alacritty
|
||||
!.pyenv
|
||||
|
||||
# zsh
|
||||
!zsh
|
||||
zsh/*
|
||||
!zsh/.zshrc
|
||||
!zsh/.zshenv
|
||||
!zsh/.zprofile
|
||||
!zsh/.zsh_plugins.txt
|
||||
!zsh/scripts.zsh
|
||||
!zsh/aliases.zsh
|
||||
!zsh/completions/.keep
|
||||
!zsh/ssh-menu
|
||||
!zsh/get-dump
|
||||
|
||||
# starship
|
||||
!starship
|
||||
@@ -25,115 +32,20 @@ starship/*
|
||||
!tmux
|
||||
tmux/*
|
||||
!tmux/tmux.conf
|
||||
!tmux/scripts
|
||||
!tmux/tmux-new-project
|
||||
!tmux/tmux-sessionizer
|
||||
!tmux/tmux-ggclone
|
||||
!tmux/tmux-ssher
|
||||
|
||||
# git
|
||||
!git
|
||||
git/*
|
||||
!git/config
|
||||
!git/ignore
|
||||
!git/config.d
|
||||
git/config.d/*
|
||||
!git/config.d/aflabs.conf
|
||||
!git/config.d/flare.conf
|
||||
!git/hooks
|
||||
git/hooks/*
|
||||
!git/hooks/merge-msg
|
||||
!git/aflabs
|
||||
|
||||
# ghostty
|
||||
!ghostty
|
||||
ghostty/*
|
||||
!ghostty/config
|
||||
!ghostty/themes
|
||||
|
||||
# sway
|
||||
!sway
|
||||
sway/*
|
||||
!sway/config
|
||||
!sway/config.d
|
||||
!sway/host.d
|
||||
|
||||
!swaylock
|
||||
swaylock/*
|
||||
!swaylock/config
|
||||
|
||||
!swayidle
|
||||
swayidle/*
|
||||
!swayidle/config
|
||||
|
||||
# waybar
|
||||
!waybar
|
||||
waybar/*
|
||||
!waybar/config.jsonc
|
||||
!waybar/style.css
|
||||
!waybar/scripts
|
||||
waybar/scripts/*
|
||||
!waybar/scripts/ssh-session.sh
|
||||
!waybar/scripts/vpn-status.sh
|
||||
!waybar/scripts/notification-status.sh
|
||||
!waybar/scripts/mic-status.sh
|
||||
!waybar/scripts/network-status.sh
|
||||
|
||||
# eww
|
||||
!eww
|
||||
eww/*
|
||||
!eww/eww.yuck
|
||||
!eww/eww.scss
|
||||
!eww/scripts
|
||||
eww/scripts/*
|
||||
!eww/scripts/popup.sh
|
||||
!eww/scripts/system.sh
|
||||
!eww/scripts/battery.sh
|
||||
!eww/scripts/volume.sh
|
||||
!eww/scripts/bluetooth.sh
|
||||
!eww/scripts/network.sh
|
||||
!eww/scripts/vpn.sh
|
||||
!eww/scripts/keyboard.sh
|
||||
!eww/scripts/media.sh
|
||||
|
||||
# sway scripts
|
||||
!sway/scripts
|
||||
sway/scripts/*
|
||||
!sway/scripts/power-menu.sh
|
||||
!sway/scripts/screenshot.sh
|
||||
!sway/scripts/ws-to-current.sh
|
||||
!sway/scripts/power-aware-effects.sh
|
||||
|
||||
# bin
|
||||
!bin
|
||||
bin/*
|
||||
!bin/ssh-menu
|
||||
!bin/git-sign-branch
|
||||
|
||||
# swaync
|
||||
!swaync
|
||||
swaync/*
|
||||
!swaync/config.json
|
||||
!swaync/style.css
|
||||
|
||||
# fuzzel
|
||||
!fuzzel
|
||||
fuzzel/*
|
||||
!fuzzel/fuzzel.ini
|
||||
|
||||
# mako
|
||||
!mako
|
||||
mako/*
|
||||
!mako/config
|
||||
|
||||
# claude
|
||||
!claude
|
||||
claude/*
|
||||
!claude/CLAUDE.md
|
||||
!claude/settings.json
|
||||
!claude/statusline-command.sh
|
||||
!claude/rules
|
||||
!claude/agents
|
||||
!claude/skills
|
||||
claude/skills/*
|
||||
!claude/skills/matej
|
||||
!claude/skills/notes
|
||||
claude/skills/notes/*
|
||||
!claude/skills/notes/SKILL.md
|
||||
!claude/skills/notes/notes.css
|
||||
!claude/skills/notes/notes.py
|
||||
|
||||
5
.pyenv/default-packages
Normal file
5
.pyenv/default-packages
Normal file
@@ -0,0 +1,5 @@
|
||||
# Neovim should be able to run anywhere
|
||||
pynvim
|
||||
|
||||
# Upgrade pip while we're at it.
|
||||
pip
|
||||
82
alacritty/alacritty.toml
Normal file
82
alacritty/alacritty.toml
Normal file
@@ -0,0 +1,82 @@
|
||||
[colors]
|
||||
draw_bold_text_with_bright_colors = true
|
||||
|
||||
[colors.bright]
|
||||
black = "0x45403d"
|
||||
blue = "0x7daea3"
|
||||
cyan = "0x89b482"
|
||||
green = "0xa9b665"
|
||||
magenta = "0xd3869b"
|
||||
red = "0xea6962"
|
||||
white = "0xd4be98"
|
||||
yellow = "0xd8a657"
|
||||
|
||||
[colors.normal]
|
||||
black = "0x45403d"
|
||||
blue = "0x7daea3"
|
||||
cyan = "0x89b482"
|
||||
green = "0xa9b665"
|
||||
magenta = "0xd3869b"
|
||||
red = "0xea6962"
|
||||
white = "0xd4be98"
|
||||
yellow = "0xd8a657"
|
||||
|
||||
[colors.primary]
|
||||
background = "0x32302f"
|
||||
foreground = "0xd4be98"
|
||||
|
||||
[cursor]
|
||||
unfocused_hollow = true
|
||||
|
||||
[cursor.style]
|
||||
blinking = "On"
|
||||
shape = "Block"
|
||||
|
||||
[env]
|
||||
TERM = "xterm-256color"
|
||||
|
||||
[font]
|
||||
size = 15
|
||||
|
||||
[font.bold]
|
||||
family = "JetBrainsMono Nerd Font"
|
||||
style = "Bold"
|
||||
|
||||
[font.bold_italic]
|
||||
family = "JetBrainsMono Nerd Font"
|
||||
style = "Bold Italic"
|
||||
|
||||
[font.italic]
|
||||
family = "JetBrainsMono Nerd Font"
|
||||
style = "Italic"
|
||||
|
||||
[font.normal]
|
||||
family = "JetBrainsMono Nerd Font"
|
||||
style = "Regular"
|
||||
|
||||
[[keyboard.bindings]]
|
||||
chars = "\f"
|
||||
key = "R"
|
||||
mode = "~Vi|~Search"
|
||||
mods = "Command"
|
||||
|
||||
[[keyboard.bindings]]
|
||||
action = "ScrollPageUp"
|
||||
key = "PageUp"
|
||||
mode = "~Alt"
|
||||
|
||||
[[keyboard.bindings]]
|
||||
action = "ScrollPageDown"
|
||||
key = "PageDown"
|
||||
mode = "~Alt"
|
||||
|
||||
[scrolling]
|
||||
history = 10000
|
||||
|
||||
[window]
|
||||
dynamic_title = true
|
||||
option_as_alt = "Both"
|
||||
|
||||
[window.dimensions]
|
||||
columns = 150
|
||||
lines = 36
|
||||
@@ -1,74 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# git sign-branch [-n] [--reset-author] [<branch> [<upstream>]]
|
||||
# re-creates every commit in <upstream>..<branch> with a signature, keeping trees, parents (mapped to
|
||||
# the new ids), author, committer, dates and message byte for byte; merges are copied, never re-run
|
||||
# --reset-author: author and committer become this repo's user.name and user.email (the signer); dates stay
|
||||
# a branch with a merge of a signed tag (mergetag header) is refused, commit-tree can't carry the tag over
|
||||
set -euo pipefail
|
||||
|
||||
dry=0 reset=0
|
||||
while [[ ${1:-} == -* ]]; do
|
||||
case $1 in
|
||||
-n | --dry-run) dry=1 ;;
|
||||
--reset-author) reset=1 ;;
|
||||
*) echo "unknown option: $1" >&2; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
if (( reset )); then
|
||||
me_name=$(git config user.name) me_email=$(git config user.email)
|
||||
fi
|
||||
branch=${1:-$(git symbolic-ref --short HEAD)}
|
||||
upstream=${2:-main}
|
||||
ref=refs/heads/$branch
|
||||
old_tip=$(git rev-parse --verify "$ref^{commit}")
|
||||
git rev-parse --verify -q "$upstream^{commit}" >/dev/null || { echo "no such upstream: $upstream" >&2; exit 1; }
|
||||
|
||||
# checked before anything is signed, so a refusal leaves no partial work
|
||||
tagged=()
|
||||
while read -r c; do
|
||||
if [[ -n $(git cat-file commit "$c" | sed -n '1,/^$/{/^mergetag /p}') ]]; then
|
||||
tagged+=("$(git log -1 --format='%h %s' "$c")")
|
||||
fi
|
||||
done < <(git rev-list "$upstream..$old_tip")
|
||||
if (( ${#tagged[@]} )); then
|
||||
echo "refusing to sign $branch: commit-tree can't carry over the signed tag these merges embed (mergetag):" >&2
|
||||
printf ' %s\n' "${tagged[@]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
declare -A new # old id -> new id, only for commits in the range
|
||||
msg=$(mktemp); trap 'rm -f "$msg"' EXIT
|
||||
count=0
|
||||
|
||||
while read -r c parents; do
|
||||
args=() changed=0
|
||||
for p in $parents; do
|
||||
np=${new[$p]:-$p}; [[ $np != "$p" ]] && changed=1
|
||||
args+=(-p "$np")
|
||||
done
|
||||
# a commit already signed, with the same parents (and, with --reset-author, already ours) stays as it is
|
||||
mine=1
|
||||
if (( reset )) && [[ $(git log -1 --format='%an%x1f%ae%x1f%cn%x1f%ce' "$c") != "$me_name"$'\x1f'"$me_email"$'\x1f'"$me_name"$'\x1f'"$me_email" ]]; then mine=0; fi
|
||||
if (( !changed && mine )) && git verify-commit "$c" >/dev/null 2>&1; then new[$c]=$c; continue; fi
|
||||
count=$((count + 1))
|
||||
if (( dry )); then
|
||||
echo "would sign $(git log -1 --format='%h %s' "$c")"; new[$c]=dry-$c; continue
|
||||
fi
|
||||
git cat-file commit "$c" | sed '1,/^$/d' >"$msg" # message bytes after the header block
|
||||
# a non-utf-8 commit keeps its encoding header; names are read in it so their bytes stay too
|
||||
enc=$(git cat-file commit "$c" | sed -n '1,/^$/{s/^encoding //p}')
|
||||
enc=${enc:-UTF-8}
|
||||
IFS=$'\x1f' read -r an ae ad cn ce cd < <(git log -1 --encoding="$enc" --date=raw --format='%an%x1f%ae%x1f%ad%x1f%cn%x1f%ce%x1f%cd' "$c")
|
||||
if (( reset )); then an=$me_name ae=$me_email cn=$me_name ce=$me_email; fi
|
||||
new[$c]=$(GIT_AUTHOR_NAME=$an GIT_AUTHOR_EMAIL=$ae GIT_AUTHOR_DATE=$ad \
|
||||
GIT_COMMITTER_NAME=$cn GIT_COMMITTER_EMAIL=$ce GIT_COMMITTER_DATE=$cd \
|
||||
git -c i18n.commitEncoding="$enc" commit-tree -S "${args[@]}" -F "$msg" "$c^{tree}")
|
||||
done < <(git rev-list --reverse --topo-order --parents "$upstream..$old_tip")
|
||||
|
||||
if (( dry )); then echo "$count commit(s) to sign on $branch"; exit 0; fi
|
||||
new_tip=${new[$old_tip]:-$old_tip}
|
||||
[[ $(git rev-parse "$new_tip^{tree}") == $(git rev-parse "$old_tip^{tree}") ]] || { echo "tip tree differs, ref left alone" >&2; exit 1; }
|
||||
# compare-and-swap: fails if the branch moved while we were signing
|
||||
git update-ref -m "sign-branch: signed $count commit(s)" "$ref" "$new_tip" "$old_tip"
|
||||
echo "$branch: $old_tip -> $new_tip ($count signed); undo: git update-ref $ref $old_tip"
|
||||
389
bin/ssh-menu
389
bin/ssh-menu
@@ -1,389 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# pick a host from ~/.ssh/config and every file it includes
|
||||
# ssh-menu connect to the selection
|
||||
# ssh-menu -s print the selection: "<host>" or "<host>\tluks" (ctrl-u)
|
||||
# ssh-menu -h show usage
|
||||
|
||||
# exported: the preview and the list reload run in child shells spawned by fzf
|
||||
export SSH_MENU_CONFIG="${SSH_MENU_CONFIG:-$HOME/.ssh/config}"
|
||||
|
||||
# reachability results are cached per host and profile so browsing the list
|
||||
# does not re-probe (and re-log on) the same server; ctrl-r forgets one host
|
||||
export SSH_MENU_CACHE="${XDG_RUNTIME_DIR:-/tmp}/ssh-menu-${USER:-$(id -un)}"
|
||||
export SSH_MENU_CACHE_TTL="${SSH_MENU_CACHE_TTL:-3600}"
|
||||
# seconds to wait for an sshd banner: a plain tcp connection answers within one;
|
||||
# a ProxyCommand such as an iap tunnel needs a few to come up first
|
||||
export SSH_MENU_TIMEOUT="${SSH_MENU_TIMEOUT:-1}"
|
||||
export SSH_MENU_PROXY_TIMEOUT="${SSH_MENU_PROXY_TIMEOUT:-3}"
|
||||
[[ -d $SSH_MENU_CACHE ]] || mkdir -m 700 "$SSH_MENU_CACHE"
|
||||
|
||||
# fail early with one message instead of a cascade of command-not-found errors
|
||||
# from inside fzf's preview shells
|
||||
function _ssh_menu_require {
|
||||
local cmd missing=()
|
||||
for cmd in "$@"; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd")
|
||||
done
|
||||
if (( ${#missing[@]} > 0 )); then
|
||||
printf 'ssh-menu: missing required commands: %s\n' "${missing[*]}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# config plus every file named on Include lines; globs expanded, relative
|
||||
# paths resolved against ~/.ssh, unreadable files skipped
|
||||
function _ssh_menu_files {
|
||||
local line paths path file
|
||||
printf '%s\n' "$SSH_MENU_CONFIG"
|
||||
grep -iE '^[[:space:]]*Include[[:space:]]' "$SSH_MENU_CONFIG" 2>/dev/null \
|
||||
| while read -r line; do
|
||||
paths=${line#*[Ii]nclude}
|
||||
for path in $paths; do
|
||||
path=${path/#\~/$HOME}
|
||||
[[ $path != /* ]] && path="$HOME/.ssh/$path"
|
||||
for file in $path; do
|
||||
[[ -r $file ]] && printf '%s\n' "$file"
|
||||
done
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
# one line per alias: file, alias, state, group, unlock marker
|
||||
# group is the fragment name; a "# unlock" comment inside a Host block, or on
|
||||
# the line directly above it, marks the host as having an initrd sshd
|
||||
function _ssh_menu_hosts {
|
||||
local file group
|
||||
while read -r file; do
|
||||
group=$(basename "$file")
|
||||
group=${group#ssh-fragment-}
|
||||
awk -v file="$file" -v group="$group" '
|
||||
function flush() {
|
||||
for (i = 0; i < n; i++)
|
||||
printf "%s\t%s\t%s\t%s\n", file, hosts[i], group, (marks > 0 ? "unlock" : "")
|
||||
n = 0; marks = 0
|
||||
}
|
||||
# unset would compare equal to 0, i.e. "the line before line 1"
|
||||
BEGIN { marker_line = -1 }
|
||||
/^[[:space:]]*[Hh]ost[[:space:]]/ {
|
||||
# a marker on the line directly above belongs to this block; the
|
||||
# previous block keeps any markers of its own
|
||||
carry = marker_line == NR - 1
|
||||
if (carry) marks--
|
||||
flush()
|
||||
marks = carry
|
||||
for (i = 2; i <= NF; i++) if ($i !~ /[*?!]/) hosts[n++] = $i
|
||||
next
|
||||
}
|
||||
/^[[:space:]]*[Mm]atch[[:space:]]/ { flush(); next }
|
||||
/^[[:space:]]*#[[:space:]]*unlock/ { marks++; marker_line = NR }
|
||||
END { flush() }
|
||||
' "$file"
|
||||
done < <(_ssh_menu_files | awk '!seen[$0]++') \
|
||||
| sort -t $'\t' -k3,3 -k2,2 \
|
||||
| _ssh_menu_with_state
|
||||
}
|
||||
|
||||
# $1 host, $2 now (epoch): one word summarising the cached default-profile
|
||||
# probe, "?" when nothing fresh is cached. reads the cache only, never probes
|
||||
function _ssh_menu_state {
|
||||
local host=$1 now=$2 file="$SSH_MENU_CACHE/$1__default" line
|
||||
[[ -f $file ]] || { echo "?"; return; }
|
||||
(( now - $(stat -c %Y "$file") < SSH_MENU_CACHE_TTL )) || { echo "?"; return; }
|
||||
line=$(<"$file")
|
||||
case $line in
|
||||
"jump "*" reachable"*) echo hop ;;
|
||||
"jump "*) echo down ;;
|
||||
reachable*) echo up ;;
|
||||
unreachable*) echo down ;;
|
||||
*) echo - ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# insert the cached state as the third field: file, alias, state, group, marker.
|
||||
# fzf keeps each field's trailing delimiter and the last field has none, so the
|
||||
# state must not be last or --with-nth would glue it to its neighbour
|
||||
function _ssh_menu_with_state {
|
||||
local now file host group marker
|
||||
now=$(date +%s)
|
||||
while IFS=$'\t' read -r file host group marker; do
|
||||
printf '%s\t%s\t%s\t%s\t%s\n' "$file" "$host" "$(_ssh_menu_state "$host" "$now")" "$group" "$marker"
|
||||
done
|
||||
}
|
||||
|
||||
# the raw Host block for an alias, comments included
|
||||
function _ssh_menu_block {
|
||||
local file=$1 host=$2
|
||||
awk -v h="$host" '
|
||||
/^[[:space:]]*[Hh]ost[[:space:]]/ {
|
||||
found = 0
|
||||
for (i = 2; i <= NF; i++) if ($i == h) found = 1
|
||||
}
|
||||
/^[[:space:]]*[Mm]atch[[:space:]]/ { found = 0 }
|
||||
found
|
||||
' "$file"
|
||||
}
|
||||
|
||||
# an ssh server announces itself before the client sends anything, so a bare
|
||||
# tcp connection (or a running ProxyCommand) yields the banner without any
|
||||
# authentication and therefore without touching the agent or the yubikey
|
||||
|
||||
# $1 host, $2 port: prints the sshd banner if the port answers with one
|
||||
function _ssh_menu_tcp_banner {
|
||||
# shellcheck disable=SC2016 # $1/$2/$3 are the inner bash's positional args
|
||||
timeout "$SSH_MENU_TIMEOUT" bash -c '
|
||||
exec 3<>"/dev/tcp/$1/$2" || exit 1
|
||||
IFS= read -t "$3" -r banner <&3 || exit 1
|
||||
printf "%s\n" "$banner" | tr -d "\r"
|
||||
' _ "$1" "$2" "$SSH_MENU_TIMEOUT" 2>/dev/null
|
||||
}
|
||||
|
||||
# $1 expanded ProxyCommand: the remote banner arrives on its stdout. stdin is an
|
||||
# open, silent pipe (not /dev/null): a tunnel command may exit on stdin EOF
|
||||
# before the banner arrives. the sleep lives in a process substitution, not the
|
||||
# pipeline, so it is not waited for: the result returns as soon as the first
|
||||
# line arrives, or after the proxy timeout
|
||||
function _ssh_menu_proxy_banner {
|
||||
timeout "$SSH_MENU_PROXY_TIMEOUT" bash -c "$1" < <(sleep "$((SSH_MENU_PROXY_TIMEOUT + 1))") 2>/dev/null | head -n1 | tr -d '\r'
|
||||
}
|
||||
|
||||
# ssh reading the same config ssh itself would; -F only when overridden, since
|
||||
# -F also drops /etc/ssh/ssh_config
|
||||
function _ssh_menu_sshflags {
|
||||
[[ $SSH_MENU_CONFIG != "$HOME/.ssh/config" ]] && printf '%s\n' -F "$SSH_MENU_CONFIG"
|
||||
return 0
|
||||
}
|
||||
|
||||
function _ssh_menu_ssh {
|
||||
local flags=()
|
||||
mapfile -t flags < <(_ssh_menu_sshflags)
|
||||
# shellcheck disable=SC2029 # plain argument passthrough, no remote command built here
|
||||
ssh "${flags[@]}" "$@"
|
||||
}
|
||||
|
||||
function _ssh_menu_resolve {
|
||||
local host=$1 tag=$2
|
||||
_ssh_menu_ssh -G ${tag:+-P "$tag"} -- "$host" 2>/dev/null
|
||||
}
|
||||
|
||||
# effective config for one profile plus a (cached) reachability probe
|
||||
function _ssh_menu_probe {
|
||||
local host=$1 tag=$2 resolved
|
||||
if ! resolved=$(_ssh_menu_resolve "$host" "$tag") || [[ -z $resolved ]]; then
|
||||
echo "ssh -G failed for $host${tag:+ (-P $tag)}"
|
||||
return
|
||||
fi
|
||||
|
||||
# identityfile only when the profile pins one; ssh -G lists all defaults otherwise
|
||||
awk '
|
||||
$1 == "identitiesonly" && $2 == "yes" { pinned = 1 }
|
||||
$1 ~ /^(hostname|port|user|tag|proxyjump|proxycommand|userknownhostsfile)$/ { print }
|
||||
$1 == "identityfile" && pinned { print }
|
||||
' <<<"$resolved"
|
||||
echo ""
|
||||
_ssh_menu_reach_cached "$host" "$tag" "$resolved"
|
||||
}
|
||||
|
||||
function _ssh_menu_age {
|
||||
local s=$1
|
||||
if (( s < 60 )); then echo "${s}s"
|
||||
elif (( s < 3600 )); then echo "$(( s / 60 ))m"
|
||||
else echo "$(( s / 3600 ))h"
|
||||
fi
|
||||
}
|
||||
|
||||
# $1 host: drop cached results for every profile of that host
|
||||
function _ssh_menu_forget {
|
||||
rm -f -- "$SSH_MENU_CACHE/$1__"*
|
||||
}
|
||||
|
||||
# $1 host, $2 unlock marker: forget and re-probe now, so the list can be
|
||||
# reloaded with the fresh state immediately instead of on the next timer tick
|
||||
function _ssh_menu_reprobe {
|
||||
_ssh_menu_forget "$1"
|
||||
_ssh_menu_probe "$1" "" >/dev/null
|
||||
[[ $2 == unlock ]] && _ssh_menu_probe "$1" luks >/dev/null
|
||||
return 0
|
||||
}
|
||||
|
||||
# $1 host, $2 tag, $3 ssh -G output: reachability line, served from cache while fresh
|
||||
function _ssh_menu_reach_cached {
|
||||
local host=$1 tag=$2 resolved=$3 file age result
|
||||
file="$SSH_MENU_CACHE/${host}__${tag:-default}"
|
||||
if [[ -f $file ]]; then
|
||||
age=$(( $(date +%s) - $(stat -c %Y "$file") ))
|
||||
if (( age < SSH_MENU_CACHE_TTL )); then
|
||||
printf '%s (cached %s ago, ctrl-r re-probes)\n' "$(<"$file")" "$(_ssh_menu_age "$age")"
|
||||
return
|
||||
fi
|
||||
fi
|
||||
result=$(_ssh_menu_reach "$host" "$tag" "$resolved")
|
||||
printf '%s\n' "$result" > "$file"
|
||||
printf '%s\n' "$result"
|
||||
}
|
||||
|
||||
# $1 host, $2 tag, $3 ssh -G output: probe the endpoint, one line of result
|
||||
function _ssh_menu_reach {
|
||||
local host=$1 tag=$2 resolved=$3 hostname port user proxycommand proxyjump banner
|
||||
local hop hopuser hophost hopport hopresolved hopopts sshflags cmd
|
||||
hostname=$(awk '$1 == "hostname" { print $2 }' <<<"$resolved")
|
||||
port=$(awk '$1 == "port" { print $2 }' <<<"$resolved")
|
||||
user=$(awk '$1 == "user" { print $2 }' <<<"$resolved")
|
||||
proxycommand=$(awk '$1 == "proxycommand" { $1 = ""; sub(/^ /, ""); print }' <<<"$resolved")
|
||||
proxyjump=$(awk '$1 == "proxyjump" && $2 != "none" { print $2 }' <<<"$resolved")
|
||||
|
||||
if [[ -n $proxyjump ]]; then
|
||||
# the target is only reachable through the hop, and opening a forwarded
|
||||
# channel needs an authenticated session there. if a ControlMaster to
|
||||
# the hop is already alive we ride it (no auth, no agent); otherwise
|
||||
# only the hop's own port is probed
|
||||
hop=${proxyjump%%,*}
|
||||
hopuser=""
|
||||
[[ $hop == *@* ]] && hopuser=${hop%%@*}
|
||||
hop=${hop##*@}
|
||||
hopport=""
|
||||
if [[ $hop == *:* ]]; then
|
||||
hopport=${hop##*:}
|
||||
hop=${hop%%:*}
|
||||
fi
|
||||
# the ControlPath hash covers user and port, so match the jump's exactly
|
||||
hopopts=(${hopuser:+-l "$hopuser"} ${hopport:+-p "$hopport"})
|
||||
if _ssh_menu_ssh -O check "${hopopts[@]}" -- "$hop" >/dev/null 2>&1; then
|
||||
# timeout runs a binary, not a shell function, hence the explicit flags
|
||||
mapfile -t sshflags < <(_ssh_menu_sshflags)
|
||||
banner=$(timeout "$SSH_MENU_TIMEOUT" ssh "${sshflags[@]}" -o BatchMode=yes -o IdentityAgent=none -o ControlMaster=no \
|
||||
"${hopopts[@]}" -W "$hostname:$port" -- "$hop" < <(sleep "$((SSH_MENU_TIMEOUT + 1))") 2>/dev/null | head -n1 | tr -d '\r')
|
||||
if [[ $banner == SSH-* ]]; then
|
||||
echo "reachable via $hop ($hostname:$port, $banner)"
|
||||
else
|
||||
echo "unreachable via $hop ($hostname:$port)"
|
||||
fi
|
||||
else
|
||||
hopresolved=$(_ssh_menu_resolve "$hop" "")
|
||||
hophost=$(awk '$1 == "hostname" { print $2 }' <<<"$hopresolved")
|
||||
hopport=${hopport:-$(awk '$1 == "port" { print $2 }' <<<"$hopresolved")}
|
||||
if banner=$(_ssh_menu_tcp_banner "$hophost" "$hopport"); then
|
||||
echo "jump $hop reachable ($hophost:$hopport, $banner); target not probed: no master (ssh -O check ${hopopts[*]} $hop)"
|
||||
else
|
||||
echo "jump $hop unreachable ($hophost:$hopport)"
|
||||
fi
|
||||
fi
|
||||
elif [[ -n $proxycommand ]]; then
|
||||
# running an ssh-based proxy would authenticate to the hop through the agent
|
||||
# matches "ssh", "/path/to/ssh", "sh -c 'ssh ..." but not "sshuttle" or "foo-ssh"
|
||||
if [[ $proxycommand =~ (^|[^[:alnum:]_.-])ssh([[:space:]]|$) ]]; then
|
||||
echo "proxycommand runs ssh; not probed (would need the agent)"
|
||||
else
|
||||
# ProxyCommand accepts exactly %%, %h, %n, %p and %r (ssh_config(5) TOKENS);
|
||||
# %% first so it cannot form a token
|
||||
cmd=${proxycommand//%%/$'\x01'}
|
||||
cmd=${cmd//%h/$hostname}
|
||||
cmd=${cmd//%p/$port}
|
||||
cmd=${cmd//%r/$user}
|
||||
cmd=${cmd//%n/$host}
|
||||
cmd=${cmd//$'\x01'/%}
|
||||
banner=$(_ssh_menu_proxy_banner "$cmd")
|
||||
if [[ $banner == SSH-* ]]; then
|
||||
echo "reachable via proxycommand ($banner)"
|
||||
else
|
||||
echo "unreachable via proxycommand"
|
||||
fi
|
||||
fi
|
||||
elif banner=$(_ssh_menu_tcp_banner "$hostname" "$port"); then
|
||||
echo "reachable ($hostname:$port, $banner)"
|
||||
else
|
||||
echo "unreachable ($hostname:$port)"
|
||||
fi
|
||||
}
|
||||
|
||||
# $1 file, $2 alias, $3 unlock marker
|
||||
function _ssh_menu_preview {
|
||||
local file=$1 host=$2 marker=$3
|
||||
_ssh_menu_block "$file" "$host"
|
||||
echo ""
|
||||
echo "--- ssh -G"
|
||||
_ssh_menu_probe "$host" ""
|
||||
if [[ $marker == unlock ]]; then
|
||||
echo ""
|
||||
echo "--- ssh -G -P luks"
|
||||
_ssh_menu_probe "$host" luks
|
||||
fi
|
||||
}
|
||||
|
||||
export -f _ssh_menu_files _ssh_menu_hosts _ssh_menu_state _ssh_menu_with_state \
|
||||
_ssh_menu_block _ssh_menu_tcp_banner _ssh_menu_proxy_banner _ssh_menu_sshflags _ssh_menu_ssh _ssh_menu_resolve \
|
||||
_ssh_menu_age _ssh_menu_forget _ssh_menu_reprobe _ssh_menu_reach_cached _ssh_menu_reach _ssh_menu_probe _ssh_menu_preview
|
||||
|
||||
# the state column is passive: it only reflects the cache, which the preview
|
||||
# fills as hosts are visited. a periodic reload keeps the column current; fzf
|
||||
# older than 0.73 lacks the every() event, so it is added only when supported
|
||||
function _ssh_menu_reload_opts {
|
||||
# exit 0 = option accepted and matched; an unknown event exits 2
|
||||
if printf 'x\n' | fzf --bind 'every(2):ignore' --filter x >/dev/null 2>&1; then
|
||||
printf '%s\n' '--bind' 'every(2):reload-sync(_ssh_menu_hosts)' '--track' '--id-nth' '2'
|
||||
fi
|
||||
}
|
||||
|
||||
# prints "<host>" or "<host>\tluks"
|
||||
function _ssh_menu_select {
|
||||
local key host marker reload_opts=()
|
||||
mapfile -t reload_opts < <(_ssh_menu_reload_opts)
|
||||
{
|
||||
read -r key
|
||||
IFS=$'\t' read -r _ host _ _ marker
|
||||
} < <(_ssh_menu_hosts \
|
||||
| SHELL=$(command -v bash) fzf \
|
||||
--delimiter $'\t' \
|
||||
--with-nth 2..5 \
|
||||
--nth 1,3 \
|
||||
--tabstop 24 \
|
||||
"${reload_opts[@]}" \
|
||||
--expect ctrl-u \
|
||||
--bind 'ctrl-r:execute-silent(_ssh_menu_reprobe {2} {5})+refresh-preview+reload-sync(_ssh_menu_hosts)' \
|
||||
--header 'ENTER connect, CTRL-U unlock (luks), CTRL-R re-probe' \
|
||||
--preview '_ssh_menu_preview {1} {2} {5}' \
|
||||
--preview-label 'host' \
|
||||
--border-label 'ssh-menu')
|
||||
|
||||
[[ -z $host ]] && return 0
|
||||
if [[ $key == ctrl-u ]]; then
|
||||
printf '%s\tluks\n' "$host"
|
||||
else
|
||||
printf '%s\n' "$host"
|
||||
fi
|
||||
}
|
||||
|
||||
function _ssh_menu_usage {
|
||||
cat <<'EOF'
|
||||
usage: ssh-menu [-s | -h]
|
||||
|
||||
(no args) pick a host and ssh into it
|
||||
-s pick a host and print it ("<host>" or "<host>\tluks")
|
||||
-h show this help
|
||||
EOF
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
-h | --help)
|
||||
_ssh_menu_usage
|
||||
;;
|
||||
-s)
|
||||
_ssh_menu_require fzf ssh awk timeout
|
||||
_ssh_menu_select
|
||||
;;
|
||||
"")
|
||||
_ssh_menu_require fzf ssh awk timeout
|
||||
IFS=$'\t' read -r host profile < <(_ssh_menu_select)
|
||||
[[ -z $host ]] && exit 0
|
||||
if [[ $profile == luks ]]; then
|
||||
exec ssh -P luks "$host"
|
||||
else
|
||||
exec ssh "$host"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
printf 'ssh-menu: unknown argument: %s\n' "$1" >&2
|
||||
_ssh_menu_usage >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
@@ -1,39 +0,0 @@
|
||||
# Global CLAUDE.md
|
||||
|
||||
## Output format
|
||||
- for plans, reports, design docs, and anything benefiting from diagrams, styling, or embedded images, prefer HTML over Markdown
|
||||
- for conventional files (README, CONTRIBUTING, LICENSE, CHANGELOG, etc.) use the ecosystem default
|
||||
|
||||
## Notes
|
||||
- local design docs, work handoffs and parked questions live in `.notes/` per project (see ~/.config/claude/rules/notes.md)
|
||||
|
||||
## Diagnostics
|
||||
- IMPORTANT: find the root cause before proposing a fix; never present a workaround as a fix (see ~/.config/claude/rules/diagnostics.md)
|
||||
- if a fix doesn't resolve the symptom, revisit the cause instead of layering patches
|
||||
|
||||
## Trust & verification
|
||||
- IMPORTANT: don't present plausible-sounding details as facts; verify file paths, flags, function and library behavior by reading code or docs first
|
||||
- when a claim depends on external behavior (library APIs, system semantics, game mechanics, etc.), cite the source you verified against, or mark it as unverified
|
||||
|
||||
## Planning vs execution
|
||||
- IMPORTANT: when asked to "plan", "analyze", "deep-dive", or "research", produce documents only; no Edit/Write (except to the plan file or `.notes/`) or Bash mutations until approved
|
||||
- if a request is ambiguous between planning and implementing, ask which one
|
||||
|
||||
## Scope
|
||||
- don't start unrelated work on your own; if a diff grows beyond the request, stop and confirm
|
||||
- in orchestrated work (the `matej` skill), an agent working from a brief stays inside the brief instead: it notes anything outside it as a leftover and keeps going
|
||||
|
||||
## Comments
|
||||
- lowercase, no trailing punctuation
|
||||
- plain: `# descriptive but non-verbose comment`
|
||||
- keyword: `# KEYWORD:(@janezicmatej) descriptive but non-verbose comment`
|
||||
- keywords: WARN, FIX, NOTE, TODO, PERF, TEST, HACK
|
||||
- keyword comments must always include the `(@janezicmatej)` attribution
|
||||
|
||||
## Git
|
||||
- agents never sign commits, in any form: they are not me. commit with `--no-gpg-sign`; I re-sign branches on my host (`git sign-branch`) before they are pushed
|
||||
- use the identity git already has; where it has none, commit as `-c user.name=agent -c user.email=agent@sandbox.invalid` (my host signing resets the author)
|
||||
- commit messages: a conventional-commit subject line of at most 72 characters including the prefix; a body only where the project wants one, wrapped at 72; no trailers
|
||||
- stage paths explicitly; never `git add .` or `git add -A`
|
||||
- split multi-step work into a few logical commits I can review; condense fixups before handing a branch back
|
||||
- every branch, worktree and container you create is yours to remove when the work ends; never remove ones you did not create
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
name: explorer
|
||||
description: Builds one option for an open design question in Matej's projects, as something he can inspect (runnable code on a prototype branch, a canvas frame), often alongside another explorer working the same brief independently. Use when the lead wants options to compare before deciding.
|
||||
---
|
||||
|
||||
You answer an open question with an option Matej can look at and judge, not with a final implementation. Prototypes may cut corners where it doesn't change what he is judging; say which corners. If another explorer works the same brief, don't look at its work.
|
||||
|
||||
Read `~/.config/claude/CLAUDE.md` and the project's AGENTS.md first. Work on the prototype branch your brief names; never merge, never push, never sign. Make the option inspectable the way the brief asks (a preview he can run on his host, screenshots, a frame), and leave what he needs to look at in place; stop your own servers and containers.
|
||||
|
||||
Your reply is text: what the option is and why, how to look at it (exact commands and URLs), what you tried and dropped, its weak spots, what you created.
|
||||
@@ -1,10 +0,0 @@
|
||||
---
|
||||
name: junior
|
||||
description: Implementer for one part of a larger unit of work, briefed by a senior agent - builds its part on its own branch and worktree, runs the checks for it, and reports back for review. Use when a senior splits work into parallel parts.
|
||||
---
|
||||
|
||||
You build one part of a unit of work that a senior agent owns. Stay inside your brief: anything outside it goes in your report as a leftover, not into your branch. When the brief leaves a choice, take the simplest option and say so.
|
||||
|
||||
Read `~/.config/claude/CLAUDE.md`, the project's AGENTS.md and the house-rule skills it points to before writing code. Work on the branch and worktree your brief names; commit a few logical commits, never sign, never merge, never push.
|
||||
|
||||
Run the checks your brief lists before reporting, and stop your own servers and containers when you are done. Your reply is text: the branch and commits, what you built, the checks with their results, anything you could not do or verify, what you created.
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
name: researcher
|
||||
description: Answers a factual or technical question for Matej with sources and, where it can, a test in a throwaway sandbox - e.g. how a git or library feature behaves, which of several approaches holds up. Use when the lead needs a verified answer, not an implementation.
|
||||
---
|
||||
|
||||
You find out, and you show how you know. Check claims against primary sources (official docs, man pages, source code) and cite them; mark anything you could not verify. Where a claim can be tested, test it in a throwaway setup under `$SANDBOX_VM_STATE/<your topic>/` and show the trimmed commands and output; never touch real keyrings, credentials or project repos.
|
||||
|
||||
Read `~/.config/claude/CLAUDE.md` first for how Matej wants facts handled. Your reply is text: the answer first (a short recommendation), then the evidence per option, then what is unverified. Leave your sandbox folder for review and say where it is.
|
||||
@@ -1,12 +0,0 @@
|
||||
---
|
||||
name: senior
|
||||
description: Senior developer for one unit of work in Matej's projects - builds it on a branch (alone or with junior agents in parallel), reviews and condenses their work, runs every check, and hands back one branch that already works. Use when the lead session delegates implementation from a brief.
|
||||
---
|
||||
|
||||
You are the senior developer for one unit of work. The lead session that briefed you talks to Matej; you don't, so when the brief leaves a choice, take the simplest option that works, keep going, and list it under "decided along the way".
|
||||
|
||||
How Matej likes this run is in `~/.config/claude/skills/matej/SKILL.md` (roles, ownership, clean-up) and `~/.config/claude/CLAUDE.md` (git, comments, verification). Read both, then the project's AGENTS.md and any house-rule skills it points to, before writing code.
|
||||
|
||||
You own the result: a branch that works, lint and format clean, every test suite green the way CI runs it, the change looked at in a browser when it has UI, reviewed for bugs. Split work across `junior` agents in parallel when the parts don't step on each other; review each part before you take it in. Never merge into the main branch, never push, never sign. Remove every branch, worktree, container and server you or your juniors created once it is merged into your branch or no longer needed, and nothing you didn't create.
|
||||
|
||||
Your reply is text: the branch and its commits, what each delivers, the checks you ran with their results, what differs from the brief, what you decided along the way, what you created and removed, and what is left. Say plainly what you could not verify.
|
||||
@@ -1,8 +0,0 @@
|
||||
# Diagnostics rules
|
||||
|
||||
The long form of the headline rules in `CLAUDE.md`.
|
||||
|
||||
- Say what the cause is, with the evidence (file and line, log excerpt, command output), before proposing a fix. For system, boot, network and regression bugs, also say which other cause you ruled out and why.
|
||||
- A workaround is labelled as one, with what would have to change to make it unnecessary. Don't mask an error with a try/except, a retry or a flag without asking first.
|
||||
- If the fix doesn't resolve the symptom, go back to the cause; don't stack another patch on top.
|
||||
- Done means the reported behaviour is now right, checked by re-running what failed. An error that went away is not enough. If the fix touched a process, daemon or background job, check it left nothing behind (processes, sockets, locks, temp files).
|
||||
@@ -1,78 +0,0 @@
|
||||
# Notes rules
|
||||
|
||||
Each project keeps its local working notes in `.notes/` at the project root: design docs, work that spans sessions, and questions parked for later. The notes are never committed to the project. The `notes` skill does the mechanics (creating the dir, picking ids, close-out).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
.notes/ # its own git repo, never pushed
|
||||
README.md # one line pointing here
|
||||
index.html # generated: the overview, links only
|
||||
notes.css # generated: copy of the skill's stylesheet every page uses
|
||||
_pages/ # generated: questions, briefs and other markdown rendered to pages
|
||||
_out/ # generated: other/out/ made self-contained, css inlined, for sending
|
||||
docs/ # dNNN-slug.html, or dNNN-slug/index.html with its assets
|
||||
work/ # wNNN-slug/ while in flight, removed at close-out
|
||||
questions/ # qNNN-slug.md, removed when settled
|
||||
other/
|
||||
in/ # placed by the user
|
||||
out/ # prepared for someone else
|
||||
```
|
||||
|
||||
- created on first need (the first doc, work item or question in a project), not up front
|
||||
- the generated files are rebuilt by the skill's `notes.py` on every commit and never committed; browse every project's notes at once with `notes.py serve` (one port, roots `$NOTES_ROOTS`, else `~/git`), or one project with `python -m http.server -d .notes`
|
||||
- every page has a back link to the index; ids like `d007` or `q014` in markdown become links
|
||||
|
||||
## Numbering
|
||||
|
||||
- `<letter><NNN>-<slug>`: `d` docs, `w` work, `q` questions; each letter has its own counter, three digits, lowercase kebab slug
|
||||
- numbers are never reused, including numbers of deleted items: the next id is the highest ever used in that bucket (files plus `git -C .notes log --all --name-only`) + 1
|
||||
- refer to items by id everywhere: chat, other notes, commit subjects ("see q014", "per d007")
|
||||
- `other/` is not numbered
|
||||
|
||||
## docs
|
||||
|
||||
- point-in-time design docs: plans, proposals, research, reviews; html per the output format rules
|
||||
- one look for all docs: link `notes.css`, start `main.page` with `nav.back`, use only the components documented at the top of `notes.css`, no `<style>` or font links of their own; the skill shows the skeleton
|
||||
- a doc with assets becomes a folder: `dNNN-slug/index.html` plus its files, linked relatively
|
||||
- a status line sits directly under the title as `<p class="status current|stale|superseded">…</p>` (`notes.py` reads it): `status: current`, `status: partly stale (<what changed>, <date>)` or `status: superseded by dNNN (<date>)`, with `dNNN` a relative link to the newer doc
|
||||
- once done, a doc's body doesn't change; only its status line does. a new design gets a new doc
|
||||
|
||||
## work
|
||||
|
||||
- one folder per piece of work that spans sessions; a single-session task gets none
|
||||
- typical files: `brief.md` (goal, done when, scope, decisions to ask), `plan.html` (the proposal to approve), `report.md` (what was built, what differs, leftovers), plus research the work needed
|
||||
- close-out runs in the same session the work ends (branch merged or work dropped):
|
||||
1. lasting design goes to `docs/`: a new doc, or a status line on an existing one
|
||||
2. leftovers go to `questions/`, one each, with `origin: wNNN`
|
||||
3. delete the folder and commit
|
||||
- a folder untouched for weeks with no open branch gets flagged to the user, not closed silently
|
||||
|
||||
## questions
|
||||
|
||||
- one file per item: something deferred, handed off or out of scope for the current session
|
||||
- frontmatter: `created` (date), `origin` (`wNNN`, `dNNN`, a branch, or `<commit>:<path>` in `.notes`), `status: open`
|
||||
- body: what is open, why it matters, what is known, where to look
|
||||
- before acting on one, check the repo for whether it still applies
|
||||
- settled: fold the outcome into a doc or a work item if it is worth keeping, delete the file, commit with a subject saying how it was settled
|
||||
|
||||
## other
|
||||
|
||||
- `in/`: read only when the user points at it
|
||||
- `out/`: written for a person, not for later sessions; don't treat it as context
|
||||
- html in `out/` uses the same `notes.css` and components as docs, without an id or status line; the copy that leaves is the generated self-contained one in `_out/`
|
||||
- `out/` holds documents and the few small files that go with them (a script, a config snippet); bulk output for a person (screenshot sets, builds, logs) does not go into `.notes/` at all: in a sandbox vm it goes to the host handoff mount once there is one, until then ask where
|
||||
|
||||
## History
|
||||
|
||||
- `.notes/` is its own git repo with no remote, its `.gitignore` holding the generated files; commit after each change set, subject line only, naming the ids touched (`q014: settled by a3f4573`)
|
||||
- git history replaces archives: delete instead of moving to an archive folder, rewrite instead of keeping dated copies
|
||||
- never stage `.notes/` from the project repo: stage project paths explicitly, never `git add .` or `git add -A` there
|
||||
- the host's global gitignore hides `.notes/`; where it shows up as untracked (sandbox vms), that is expected
|
||||
|
||||
## What doesn't go in notes
|
||||
|
||||
- facts every session needs: the project's `AGENTS.md`/`CLAUDE.md`, or memory when they are personal
|
||||
- anything teammates need: the project repo
|
||||
- secrets
|
||||
- committed project files never reference `.notes/` paths
|
||||
@@ -1,32 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__todo-mcp__*",
|
||||
"mcp__nixos__*",
|
||||
"Bash(git remote get-url origin)"
|
||||
]
|
||||
},
|
||||
"model": "opus[1m]",
|
||||
"hooks": {},
|
||||
"statusLine": {
|
||||
"type": "command",
|
||||
"command": "bash \"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/statusline-command.sh\""
|
||||
},
|
||||
"enabledPlugins": {
|
||||
"typesafe@typesafe-ai": true
|
||||
},
|
||||
"extraKnownMarketplaces": {
|
||||
"typesafe-ai": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "typesafe-ai/skills"
|
||||
}
|
||||
}
|
||||
},
|
||||
"effortLevel": "high",
|
||||
"tui": "default",
|
||||
"skipDangerousModePermissionPrompt": true,
|
||||
"theme": "dark",
|
||||
"voiceEnabled": true,
|
||||
"mcpServers": {}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
---
|
||||
name: matej
|
||||
description: How Matej likes larger work run - one lead session that talks to him and briefs agents, a senior agent that builds and hands back a checked branch, explorer agents for open questions, notes and a design canvas for review. Use when he asks for orchestrated work ("let the senior handle it", "spawn agents for this", parallel options to compare) or for work spanning several branches or sessions.
|
||||
---
|
||||
|
||||
# Running work through agents
|
||||
|
||||
Guidelines, not a procedure. Where a step is obvious, do it your way; what is written here is what isn't knowable from the repo.
|
||||
|
||||
## Roles
|
||||
|
||||
- **Lead**: the session Matej talks to. Discusses, locks decisions, keeps the notes, briefs agents, checks what they report and tells him plainly what is done, what is open and what it took on trust. It doesn't edit the project itself, so its context stays free for judgement.
|
||||
- **Senior**: owns a unit of work from brief to a branch that works, and hands back that one branch, never merging it.
|
||||
- When the work splits into parts that don't step on each other, it runs implementer agents in parallel, each on its own branch and worktree under the work id, briefed the way the lead briefs it. A few at a time; what everything else builds on (a shared shell, a schema) goes first, alone. Small or tightly coupled work it does itself.
|
||||
- It reviews each part before taking it in (reads the diff, runs code-review and simplify, fixes what is real), condenses its history, and merges it into the work's integration branch, sorting out what parallel parts collide on (migrations, generated code).
|
||||
- It runs every check the project has on the integration branch, not only on the parts, and looks at what changed in a browser when there is UI.
|
||||
- It removes its implementers' branches, worktrees and containers once they are merged, and its own when it hands back; the lead checks.
|
||||
- **Explorers**: answer an open question with something Matej can inspect, often two independently on the same brief so he can compare. Prototypes may cut corners; they never land as they are.
|
||||
|
||||
## Protocol
|
||||
|
||||
- Discuss, lock, relay. Only locked decisions go to builders, in the words that were agreed. When a relay replaces an earlier one, say what it replaces; when unsure what Matej meant, ask before relaying.
|
||||
- Builders keep going while the next point is discussed; new locked items join the open branch.
|
||||
- Builders decide what the brief doesn't settle the simple way and list it as "decided along the way". Only the lead asks Matej.
|
||||
|
||||
## Spawning
|
||||
|
||||
Use the agent types for the roles (`senior`, `junior`, `explorer`, `researcher` in `~/.config/claude/agents/`), not a general-purpose agent, so Matej sees who is doing what. An agent's label is set once, when it is spawned, so make it the top level: the unit of work it is for ("graph redesign", "how it works redraw", "signing agent commits"), never its first step. What it is doing right now is the activity Claude Code shows beside the label; that part takes care of itself. When a running agent is given a new item, the label stays as it was, so say the new item in the relay's first line.
|
||||
|
||||
## A brief carries
|
||||
|
||||
The goal and what done looks like; where the spec is (note ids, canvas frames, branches); the decisions that touch it; scope and what not to touch; pointers to the rules (AGENTS.md, CLAUDE.md, the house skills) rather than copies; the checks; the shape of the reply; what it may create and must clean up. Agents don't see the lead's memory: anything they need from it goes in the brief.
|
||||
|
||||
## Before passing a report on
|
||||
|
||||
Check the cheap facts yourself: branch heads, commit authors and subject lengths, nothing staged that shouldn't be, worktrees and containers gone. Re-run something long only when a claim looks off. Say what you checked.
|
||||
|
||||
## Where things go
|
||||
|
||||
- Plans, decisions, reports, leftovers: the project's `.notes/` (the notes skill). Agents can't write there; the lead files their reports.
|
||||
- Visual design to discuss: the Design canvas (an Artifact), kept current; history frames get removed, not piled up.
|
||||
- Prototypes and finished work: branches. The repo's `.git` is on Matej's host, so he can `git worktree add --detach` any branch there.
|
||||
- Bulk files for Matej (screenshots, logs): the host handoff mount once it exists; never the project repo, never bulk in `.notes/`.
|
||||
|
||||
## Ownership and clean-up
|
||||
|
||||
- Everything a unit of work creates carries its work id: branches `<wNNN>/<name>`, worktrees under `$SANDBOX_VM_STATE/wt/<wNNN>/`, containers `<wNNN>-<name>`. Agents remove what they created and nothing else.
|
||||
- The lead keeps `made.md` in the work item: each branch, worktree, container and handoff folder, with its state (open, merged, prototype).
|
||||
- Close-out removes the list: merged branches deleted, prototypes once their decision is recorded, worktrees, containers and handoff folders gone; then check nothing under the work id is left.
|
||||
|
||||
## Interruptions
|
||||
|
||||
Any agent can stop (usage limits, Matej stopping it), and a stopped agent can't be resumed. Keep state on disk: small commits on the branch, decisions and reports in the work item. Before re-briefing, look at the repo first and hand the new agent what is actually there.
|
||||
|
||||
## Git
|
||||
|
||||
Agents never sign; Matej re-signs on his host before pushing (CLAUDE.md, Git).
|
||||
@@ -1,116 +0,0 @@
|
||||
---
|
||||
name: notes
|
||||
description: Manage the project's local .notes/ dir (design docs, work handoffs, parked questions). Use whenever writing a design doc, plan, proposal, research or review for local use; starting, handing off or closing out work that spans sessions (brief, plan, report); parking something for later, out of scope or a follow-up; picking up or settling a parked question; preparing a document for someone else; or when the user mentions .notes, handoffs, open questions or asks what is pending.
|
||||
---
|
||||
|
||||
# Notes
|
||||
|
||||
The policy is `~/.config/claude/rules/notes.md`: layout, numbering, status lines, close-out, what doesn't belong. This skill holds the procedures. Every step that changes `.notes/` ends with `commit`, which also regenerates `index.html`, `notes.css`, the markdown pages under `_pages/` and the portable copies under `_out/`. Change the look only in this skill's `notes.css`, never in a project.
|
||||
|
||||
```
|
||||
N=~/.config/claude/skills/notes/notes.py
|
||||
python3 $N init # create .notes/ if missing (idempotent)
|
||||
python3 $N next d|w|q # next free id, counts deleted items too
|
||||
python3 $N commit "<subject>" # regenerate the generated pages, commit all of .notes/
|
||||
python3 $N index # regenerate index.html, notes.css, _pages/ and _out/ only
|
||||
python3 $N serve # every project's notes on http://127.0.0.1:8765/: hub filters projects, each project page searches its notes (the user runs this)
|
||||
```
|
||||
|
||||
All commands take `--root <project>`; the default is the git toplevel of the current dir. Never pick an id by hand or from `ls`; deleted ids live only in git history.
|
||||
|
||||
## New doc
|
||||
|
||||
1. `init`, then `next d`
|
||||
2. write `docs/dNNN-slug.html` (or `docs/dNNN-slug/index.html` with its assets next to it, then every `../` below becomes `../../`) on this skeleton, using only the components documented at the top of `notes.css` (callout, tag, cards, grid-2, table, figure.diagram with its svg classes); no `<style>`, no font links:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Short name</title>
|
||||
<link rel="stylesheet" href="../notes.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="page">
|
||||
<nav class="back"><a href="../index.html">← notes</a></nav>
|
||||
<header>
|
||||
<p class="eyebrow">dNNN · plan · 2026-10-14</p>
|
||||
<h1>Title</h1>
|
||||
<p class="status current">status: current</p>
|
||||
<p class="lede">What this doc decides, in one or two sentences.</p>
|
||||
</header>
|
||||
<section>
|
||||
<h2>…</h2>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
3. if it replaces an older doc, set the older one's status line to `<p class="status superseded">status: superseded by <a href="dNNN-slug.html">dNNN</a> (<date>)</p>`; mind the relative path when either doc is a folder
|
||||
4. look at it: a headless browser screenshot in light and dark, or `serve`
|
||||
5. `commit "dNNN: <slug>"`
|
||||
|
||||
## New work item
|
||||
|
||||
Only for work that will span sessions.
|
||||
|
||||
1. `init`, then `next w`, create `work/wNNN-slug/`
|
||||
2. `brief.md`, starting with `# wNNN <title>`: goal, done when, scope, verification, decisions to ask, prerequisites to read
|
||||
3. add `plan.html` (the proposal, on the doc skeleton with `../../notes.css` and `../../index.html`) and research files as the work goes; `report.md` at the end: what was built, what differs from the plan, leftovers
|
||||
4. `commit "wNNN: <what changed>"`
|
||||
|
||||
## Close-out
|
||||
|
||||
When the work's branch is merged or the work is dropped, in that same session:
|
||||
|
||||
1. lasting design → new doc or a status line on an existing one (see above)
|
||||
2. each leftover → a question with `origin: wNNN`
|
||||
3. `rm -r work/wNNN-slug`, then `commit "wNNN: closed, <merged as <sha>|dropped>"`
|
||||
|
||||
When `index` shows a work folder untouched for weeks with no open branch in the project, tell the user; don't close it yourself.
|
||||
|
||||
## Park a question
|
||||
|
||||
1. `init`, then `next q`
|
||||
2. write `questions/qNNN-slug.md`:
|
||||
|
||||
```
|
||||
---
|
||||
created: 2026-10-14
|
||||
origin: w009
|
||||
status: open
|
||||
---
|
||||
|
||||
# <one-line question or follow-up>
|
||||
|
||||
What is open and why it matters. What is known so far, with paths, commits and ids.
|
||||
Where to start looking.
|
||||
```
|
||||
|
||||
3. `commit "qNNN: <slug>"`
|
||||
|
||||
Write it so a session with no memory of today can act on it.
|
||||
|
||||
## Settle a question
|
||||
|
||||
1. check the repo first: it may already be done or moot
|
||||
2. fold the outcome into a doc or a work item if it is worth keeping
|
||||
3. `rm questions/qNNN-slug.md`, `commit "qNNN: settled, <how: done in <sha> | moot because ... | moved to wNNN>"`
|
||||
|
||||
## Other
|
||||
|
||||
- `other/in/`: the user's files; read only when pointed at
|
||||
- `other/out/`: documents for someone else, written for that reader; no ids, no status line
|
||||
- html follows the doc skeleton and components like any doc, with `../../notes.css` and `../../index.html`; the eyebrow names the reader and date instead of an id (`for the team · 2026-10-14`)
|
||||
- what gets sent is the generated `_out/<file>`: `notes.css` inlined, back link dropped, other files copied; the index links it as "to send"
|
||||
- look at the `_out/` copy too before handing it over, then `commit "other: <file>"`
|
||||
|
||||
## Never
|
||||
|
||||
- stage `.notes/` from the project repo (`git add .`, `git add -A`); stage project paths explicitly
|
||||
- reference `.notes/` paths from committed project files
|
||||
- keep archives or dated copies; delete, git keeps it
|
||||
- push `.notes/` or add a remote
|
||||
@@ -1,961 +0,0 @@
|
||||
/*
|
||||
* notes.css: the one stylesheet for every page in a project's .notes/
|
||||
* notes.py copies it to .notes/notes.css; edit it here, in the notes skill, never in a project
|
||||
*
|
||||
* gruvbox material (sainnhe/gruvbox-material: medium contrast, material foreground), light or dark
|
||||
* by the system scheme; jetbrains mono for all text
|
||||
*
|
||||
* page skeleton for a doc (docs/dNNN-slug.html; one level deeper for a folder doc):
|
||||
* <link rel="stylesheet" href="../notes.css">
|
||||
* <body><main class="page">
|
||||
* <nav class="back"><a href="../index.html">← notes</a></nav>
|
||||
* <header>
|
||||
* <p class="eyebrow">d007 · 2026-10-13</p>
|
||||
* <h1>Title</h1>
|
||||
* <p class="status current|stale|superseded">status: …</p>
|
||||
* <p class="lede">one or two sentences</p>
|
||||
* </header>
|
||||
* <section><h2>…</h2> …</section>
|
||||
* </main></body>
|
||||
*
|
||||
* components:
|
||||
* .callout (+ .good .warn .bad .info) a boxed note, recommendation, open point
|
||||
* .tag (+ .good .warn .bad .info .muted) a small inline label
|
||||
* .cards > .card (.card-id, strong, small) a grid of linked or plain cards
|
||||
* .grid-2 two columns, one on a phone
|
||||
* .table > table a table that scrolls sideways when wide
|
||||
* figure.diagram > svg (+ figcaption) an inline svg diagram, see "diagrams" below
|
||||
* dl.facts > dt + dd label and value pairs in two columns (repro, cause, fix)
|
||||
* pre.diff > span(.add .del) a unified diff, one span per line
|
||||
* .legend > span > svg + text a row of notation keys above a diagram
|
||||
* .swatch (+ .info .good .warn .bad) a small colour square that matches a diagram box
|
||||
* main.page.wide a wider page, for pages carried by wide diagrams
|
||||
* .sub, .muted secondary text
|
||||
* .fix a line prefixed with an arrow
|
||||
*/
|
||||
|
||||
@import url("https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap");
|
||||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--radius: 0.375rem;
|
||||
|
||||
/* gruvbox material light */
|
||||
--red: #c14a4a;
|
||||
--orange: #c35e0a;
|
||||
--yellow: #b47109;
|
||||
--green: #6c782e;
|
||||
--aqua: #4c7a5d;
|
||||
--blue: #45707a;
|
||||
--purple: #945e80;
|
||||
|
||||
--background: #fbf1c7;
|
||||
--foreground: #654735;
|
||||
--card: #f4e8be;
|
||||
--muted: #eee0b7;
|
||||
--muted-foreground: #7c6f64;
|
||||
--border: #ddccab;
|
||||
--input: #a89984;
|
||||
--link: var(--blue);
|
||||
|
||||
/* text colours and their tints, both follow the scheme's palette */
|
||||
--good: var(--green);
|
||||
--warn: var(--yellow);
|
||||
--bad: var(--red);
|
||||
--info: var(--blue);
|
||||
--good-bg: color-mix(in srgb, var(--green) 14%, transparent);
|
||||
--warn-bg: color-mix(in srgb, var(--yellow) 16%, transparent);
|
||||
--bad-bg: color-mix(in srgb, var(--red) 13%, transparent);
|
||||
--info-bg: color-mix(in srgb, var(--blue) 13%, transparent);
|
||||
--accent: var(--orange);
|
||||
--accent-bg: color-mix(in srgb, var(--orange) 12%, transparent);
|
||||
|
||||
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* gruvbox material dark */
|
||||
--red: #ea6962;
|
||||
--orange: #e78a4e;
|
||||
--yellow: #d8a657;
|
||||
--green: #a9b665;
|
||||
--aqua: #89b482;
|
||||
--blue: #7daea3;
|
||||
--purple: #d3869b;
|
||||
|
||||
--background: #282828;
|
||||
--foreground: #d4be98;
|
||||
--card: #32302f;
|
||||
--muted: #45403d;
|
||||
--muted-foreground: #a89984;
|
||||
--border: #45403d;
|
||||
--input: #5a524c;
|
||||
--good-bg: color-mix(in srgb, var(--green) 16%, transparent);
|
||||
--warn-bg: color-mix(in srgb, var(--yellow) 15%, transparent);
|
||||
--bad-bg: color-mix(in srgb, var(--red) 16%, transparent);
|
||||
--info-bg: color-mix(in srgb, var(--blue) 16%, transparent);
|
||||
--accent-bg: color-mix(in srgb, var(--orange) 15%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
/* base */
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font: 400 14px/1.65 var(--font-mono);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
main.page {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px 96px;
|
||||
}
|
||||
main.page.wide {
|
||||
max-width: 1240px;
|
||||
}
|
||||
a {
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--blue);
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
text-wrap: balance;
|
||||
margin: 0;
|
||||
}
|
||||
h1 {
|
||||
font-size: 26px;
|
||||
}
|
||||
h2 {
|
||||
font-size: 19px;
|
||||
margin: 44px 0 12px;
|
||||
padding-top: 24px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
h3 {
|
||||
font-size: 16px;
|
||||
margin: 28px 0 8px;
|
||||
}
|
||||
h4 {
|
||||
font-size: 15px;
|
||||
margin: 20px 0 6px;
|
||||
}
|
||||
section > h2:first-child {
|
||||
margin-top: 40px;
|
||||
}
|
||||
p,
|
||||
ul,
|
||||
ol,
|
||||
blockquote,
|
||||
pre,
|
||||
figure,
|
||||
.table,
|
||||
.callout,
|
||||
.cards,
|
||||
.grid-2,
|
||||
details {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
ul,
|
||||
ol {
|
||||
padding-left: 22px;
|
||||
}
|
||||
li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
li > ul,
|
||||
li > ol {
|
||||
margin: 4px 0 0;
|
||||
}
|
||||
strong,
|
||||
b {
|
||||
font-weight: 600;
|
||||
}
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 32px 0;
|
||||
}
|
||||
blockquote {
|
||||
padding: 2px 0 2px 16px;
|
||||
border-left: 3px solid var(--border);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
code,
|
||||
kbd,
|
||||
pre,
|
||||
.mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.93em;
|
||||
}
|
||||
code {
|
||||
background: var(--muted);
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
pre {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
overflow-x: auto;
|
||||
line-height: 1.5;
|
||||
}
|
||||
pre code {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-size: 1em;
|
||||
overflow-wrap: normal;
|
||||
}
|
||||
|
||||
/* page chrome */
|
||||
|
||||
nav.back {
|
||||
font-size: 13px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
nav.back a {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
nav.back a:hover {
|
||||
color: var(--link);
|
||||
}
|
||||
header {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
header > * + * {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.eyebrow {
|
||||
font: 500 12px/1.4 var(--font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted-foreground);
|
||||
margin: 0;
|
||||
}
|
||||
.lede {
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
margin-top: 14px;
|
||||
}
|
||||
.sub,
|
||||
.muted {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
p.status {
|
||||
display: inline-block;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
padding: 3px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
p.status.current {
|
||||
background: var(--good-bg);
|
||||
color: var(--good);
|
||||
}
|
||||
p.status.stale {
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn);
|
||||
}
|
||||
.fix::before {
|
||||
content: "→ ";
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* components */
|
||||
|
||||
.callout {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 3px solid var(--input);
|
||||
border-radius: var(--radius);
|
||||
padding: 10px 14px;
|
||||
}
|
||||
.callout > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.callout.good {
|
||||
background: var(--good-bg);
|
||||
border-color: transparent;
|
||||
border-left-color: var(--good);
|
||||
}
|
||||
.callout.warn {
|
||||
background: var(--warn-bg);
|
||||
border-color: transparent;
|
||||
border-left-color: var(--warn);
|
||||
}
|
||||
.callout.bad {
|
||||
background: var(--bad-bg);
|
||||
border-color: transparent;
|
||||
border-left-color: var(--bad);
|
||||
}
|
||||
.callout.info {
|
||||
background: var(--info-bg);
|
||||
border-color: transparent;
|
||||
border-left-color: var(--info);
|
||||
}
|
||||
|
||||
.tag {
|
||||
display: inline-block;
|
||||
font: 500 11px/1.5 var(--font-mono);
|
||||
letter-spacing: 0.02em;
|
||||
padding: 1px 8px;
|
||||
border-radius: 99px;
|
||||
white-space: nowrap;
|
||||
vertical-align: 1px;
|
||||
background: var(--muted);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.tag.good {
|
||||
background: var(--good-bg);
|
||||
color: var(--good);
|
||||
}
|
||||
.tag.warn {
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn);
|
||||
}
|
||||
.tag.bad {
|
||||
background: var(--bad-bg);
|
||||
color: var(--bad);
|
||||
}
|
||||
.tag.info {
|
||||
background: var(--info-bg);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.card {
|
||||
display: block;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px 14px;
|
||||
color: var(--foreground);
|
||||
}
|
||||
a.card:hover {
|
||||
border-color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
.card-id {
|
||||
display: block;
|
||||
font: 500 12px/1.4 var(--font-mono);
|
||||
color: var(--accent);
|
||||
}
|
||||
.card strong {
|
||||
display: block;
|
||||
}
|
||||
.card small {
|
||||
display: block;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 13px;
|
||||
}
|
||||
.card > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.table {
|
||||
overflow-x: auto;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
}
|
||||
th,
|
||||
td {
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
padding: 7px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
th {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
td > :last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
td .tag {
|
||||
white-space: normal;
|
||||
}
|
||||
td code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
details {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 8px 14px;
|
||||
}
|
||||
summary {
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
details[open] > summary {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
dl.facts {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
gap: 6px 16px;
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
dl.facts dt {
|
||||
color: var(--muted-foreground);
|
||||
font-weight: 500;
|
||||
}
|
||||
dl.facts dd {
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
dl.facts {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
}
|
||||
dl.facts dd + dt {
|
||||
margin-top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* one column as wide as the longest line, so every line's tint spans it when scrolled */
|
||||
pre.diff {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(max-content, 1fr);
|
||||
}
|
||||
pre.diff span {
|
||||
white-space: pre;
|
||||
}
|
||||
pre.diff .add {
|
||||
background: var(--good-bg);
|
||||
color: var(--good);
|
||||
}
|
||||
pre.diff .del {
|
||||
background: var(--bad-bg);
|
||||
color: var(--bad);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 22px;
|
||||
margin: 0 0 14px;
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.legend > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.legend svg {
|
||||
width: 34px;
|
||||
height: 20px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--muted-foreground);
|
||||
background: var(--card);
|
||||
vertical-align: 0;
|
||||
}
|
||||
.swatch.info {
|
||||
background: color-mix(in srgb, var(--blue) 14%, var(--card));
|
||||
border-color: var(--blue);
|
||||
}
|
||||
.swatch.good {
|
||||
background: color-mix(in srgb, var(--green) 14%, var(--card));
|
||||
border-color: var(--green);
|
||||
}
|
||||
.swatch.warn {
|
||||
background: color-mix(in srgb, var(--yellow) 18%, var(--card));
|
||||
border-color: var(--yellow);
|
||||
}
|
||||
.swatch.bad {
|
||||
background: color-mix(in srgb, var(--red) 14%, var(--card));
|
||||
border-color: var(--red);
|
||||
}
|
||||
|
||||
/*
|
||||
* diagrams: inline svg inside figure.diagram, drawn with these classes so both schemes work
|
||||
* <g class="node [accent|dashed|good|bad|warn]"><rect …/><text …>Label</text><text class="sub" …>detail</text></g>
|
||||
* <path class="edge [back|good|bad|warn]" … marker-end="url(#arrow)"/>
|
||||
* <path class="arrow [back|good|bad|warn]" d="M0 0 L10 5 L0 10 z"/> inside a <marker>
|
||||
* <text class="edge-label [back|good|bad|warn]" …>label</text>
|
||||
* <rect class="group" …/> and <text class="group-label" …> a dashed box around nodes
|
||||
* for drawings with their own text placement (a data flow, a sequence), classes go on the shapes:
|
||||
* <rect class="box [info|good|warn|bad|accent] [strong] [dashed] [open]"/> a step, process or entity
|
||||
* <rect class="zone [info|good|warn|bad|accent] [boundary]"/> a tinted dashed area; boundary strokes it red
|
||||
* <rect class="panel"/> a quiet solid area behind a group of boxes
|
||||
* <rect class="chip [good|accent]"/> + <text class="chip-text"> a small filled badge or numbered dot
|
||||
* <path class="line"/> a plain stroke (a data store's rails) <line class="rule"/> a faint dashed guide
|
||||
* <rect class="fill"/> a borderless card-coloured shape: a data store's body, a backdrop
|
||||
* <text class="title|sub|mono|heading [info|good|warn|bad]"> text roles; text-anchor stays an attribute
|
||||
* .edge.dashed an answer or a nudge, not a main flow
|
||||
* figure.diagram.wide svg at least 1000px wide, scrolls sideways on a phone
|
||||
* an svg may also use currentColor and var(--good|--bad|--warn|--accent) in fill and stroke attributes
|
||||
*/
|
||||
figure {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
figure.diagram {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 14px;
|
||||
overflow-x: auto;
|
||||
color: var(--foreground);
|
||||
}
|
||||
figure.diagram svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
min-width: 640px;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
figure.diagram.wide svg {
|
||||
min-width: 1000px;
|
||||
}
|
||||
figure.diagram text {
|
||||
fill: var(--foreground);
|
||||
}
|
||||
figcaption {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 13px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.node rect,
|
||||
.node circle,
|
||||
.node ellipse,
|
||||
.node polygon {
|
||||
fill: var(--card);
|
||||
stroke: var(--muted-foreground);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.node.accent rect,
|
||||
.node.accent circle,
|
||||
.node.accent polygon {
|
||||
fill: var(--accent-bg);
|
||||
stroke: var(--accent);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.node.dashed rect,
|
||||
.node.dashed circle,
|
||||
.node.dashed polygon {
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
.node.good rect {
|
||||
fill: var(--good-bg);
|
||||
stroke: var(--good);
|
||||
}
|
||||
.node.bad rect {
|
||||
fill: var(--bad-bg);
|
||||
stroke: var(--bad);
|
||||
}
|
||||
.node.warn rect {
|
||||
fill: var(--warn-bg);
|
||||
stroke: var(--warn);
|
||||
}
|
||||
.node text {
|
||||
fill: var(--foreground);
|
||||
font: 500 14px var(--font-mono);
|
||||
text-anchor: middle;
|
||||
}
|
||||
.node text.sub {
|
||||
fill: var(--muted-foreground);
|
||||
font: 400 11px var(--font-mono);
|
||||
}
|
||||
.edge {
|
||||
fill: none;
|
||||
stroke: var(--muted-foreground);
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
.arrow {
|
||||
fill: var(--muted-foreground);
|
||||
}
|
||||
.edge-label {
|
||||
fill: var(--muted-foreground);
|
||||
font: 400 11px var(--font-mono);
|
||||
text-anchor: middle;
|
||||
paint-order: stroke;
|
||||
stroke: var(--card);
|
||||
stroke-width: 4px;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
.edge.back {
|
||||
stroke: var(--accent);
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
.arrow.back,
|
||||
.edge-label.back {
|
||||
fill: var(--accent);
|
||||
}
|
||||
.edge.good {
|
||||
stroke: var(--good);
|
||||
}
|
||||
.arrow.good,
|
||||
.edge-label.good {
|
||||
fill: var(--good);
|
||||
}
|
||||
.edge.bad {
|
||||
stroke: var(--bad);
|
||||
}
|
||||
.arrow.bad,
|
||||
.edge-label.bad {
|
||||
fill: var(--bad);
|
||||
}
|
||||
.edge.warn {
|
||||
stroke: var(--warn);
|
||||
}
|
||||
.arrow.warn,
|
||||
.edge-label.warn {
|
||||
fill: var(--warn);
|
||||
}
|
||||
.group {
|
||||
fill: none;
|
||||
stroke: var(--muted-foreground);
|
||||
stroke-opacity: 0.5;
|
||||
stroke-dasharray: 5 3;
|
||||
}
|
||||
.group-label {
|
||||
fill: var(--muted-foreground);
|
||||
font: 500 11px var(--font-mono);
|
||||
}
|
||||
|
||||
/* shapes with their own classes, for drawings that place their own text */
|
||||
|
||||
svg .box {
|
||||
fill: var(--card);
|
||||
stroke: var(--muted-foreground);
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
svg .box.info {
|
||||
fill: color-mix(in srgb, var(--blue) 14%, var(--card));
|
||||
stroke: var(--blue);
|
||||
}
|
||||
svg .box.good {
|
||||
fill: color-mix(in srgb, var(--green) 14%, var(--card));
|
||||
stroke: var(--green);
|
||||
}
|
||||
svg .box.warn {
|
||||
fill: color-mix(in srgb, var(--yellow) 18%, var(--card));
|
||||
stroke: var(--yellow);
|
||||
}
|
||||
svg .box.bad {
|
||||
fill: color-mix(in srgb, var(--red) 14%, var(--card));
|
||||
stroke: var(--red);
|
||||
}
|
||||
svg .box.accent {
|
||||
fill: color-mix(in srgb, var(--orange) 14%, var(--card));
|
||||
stroke: var(--orange);
|
||||
}
|
||||
svg .box.strong {
|
||||
stroke: var(--foreground);
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
svg .box.dashed,
|
||||
svg .edge.dashed {
|
||||
stroke-dasharray: 5 4;
|
||||
}
|
||||
svg .box.open {
|
||||
fill: none;
|
||||
}
|
||||
svg .zone {
|
||||
fill: color-mix(in srgb, var(--muted-foreground) 6%, transparent);
|
||||
stroke: var(--muted-foreground);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 7 5;
|
||||
}
|
||||
svg .zone.info {
|
||||
fill: color-mix(in srgb, var(--blue) 7%, transparent);
|
||||
}
|
||||
svg .zone.good {
|
||||
fill: color-mix(in srgb, var(--green) 7%, transparent);
|
||||
}
|
||||
svg .zone.warn {
|
||||
fill: color-mix(in srgb, var(--yellow) 9%, transparent);
|
||||
}
|
||||
svg .zone.bad {
|
||||
fill: color-mix(in srgb, var(--red) 6%, transparent);
|
||||
}
|
||||
svg .zone.accent {
|
||||
fill: color-mix(in srgb, var(--orange) 7%, transparent);
|
||||
}
|
||||
svg .zone.boundary {
|
||||
stroke: var(--red);
|
||||
}
|
||||
svg .panel {
|
||||
fill: color-mix(in srgb, var(--foreground) 4%, transparent);
|
||||
stroke: var(--border);
|
||||
}
|
||||
svg .chip {
|
||||
fill: var(--blue);
|
||||
}
|
||||
svg .chip.good {
|
||||
fill: var(--green);
|
||||
}
|
||||
svg .chip.accent {
|
||||
fill: var(--orange);
|
||||
}
|
||||
svg text.chip-text {
|
||||
fill: var(--background);
|
||||
font: 700 10px var(--font-mono);
|
||||
}
|
||||
svg .fill {
|
||||
fill: var(--card);
|
||||
stroke: none;
|
||||
}
|
||||
svg .line {
|
||||
fill: none;
|
||||
stroke: var(--foreground);
|
||||
stroke-width: 1.6;
|
||||
}
|
||||
svg .rule {
|
||||
stroke: var(--border);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 3 5;
|
||||
}
|
||||
svg text.title {
|
||||
fill: var(--foreground);
|
||||
font: 600 13px var(--font-mono);
|
||||
}
|
||||
svg text.sub {
|
||||
fill: var(--muted-foreground);
|
||||
font: 400 11px var(--font-mono);
|
||||
}
|
||||
svg text.mono {
|
||||
fill: var(--muted-foreground);
|
||||
font: 400 10.5px var(--font-mono);
|
||||
}
|
||||
svg text.heading {
|
||||
fill: var(--muted-foreground);
|
||||
font: 600 11px var(--font-mono);
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
svg text.info {
|
||||
fill: var(--info);
|
||||
}
|
||||
svg text.good {
|
||||
fill: var(--good);
|
||||
}
|
||||
svg text.warn {
|
||||
fill: var(--warn);
|
||||
}
|
||||
svg text.bad {
|
||||
fill: var(--bad);
|
||||
}
|
||||
|
||||
/* index */
|
||||
|
||||
.index-head h1 {
|
||||
font-size: 26px;
|
||||
}
|
||||
.index-head p {
|
||||
margin: 4px 0 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 13px;
|
||||
}
|
||||
.index h2 {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted-foreground);
|
||||
margin: 36px 0 6px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
.index h2 .count {
|
||||
font: 500 12px var(--font-mono);
|
||||
letter-spacing: 0;
|
||||
}
|
||||
.rows {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.rows li {
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(0, 1fr) auto;
|
||||
gap: 12px;
|
||||
align-items: baseline;
|
||||
padding: 9px 14px;
|
||||
margin: 0;
|
||||
}
|
||||
.rows li + li {
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.rows .id {
|
||||
font: 500 12px var(--font-mono);
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.rows .meta {
|
||||
font-size: 12px;
|
||||
color: var(--muted-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.rows .title.superseded a {
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
.rows.plain li {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.empty {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 13px;
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.rows li {
|
||||
grid-template-columns: 44px minmax(0, 1fr);
|
||||
}
|
||||
.rows .meta {
|
||||
grid-column: 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* a page rendered from markdown */
|
||||
|
||||
.md-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
margin: 12px 0 0;
|
||||
}
|
||||
.md-meta dt {
|
||||
display: inline;
|
||||
font-weight: 500;
|
||||
}
|
||||
.md-meta dd {
|
||||
display: inline;
|
||||
margin: 0 0 0 4px;
|
||||
}
|
||||
.md-meta div {
|
||||
min-width: 0;
|
||||
}
|
||||
.files {
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
margin-top: 32px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* serve's hub: one row per project */
|
||||
|
||||
.rows.hub li {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.rows.hub li {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.rows.hub .meta {
|
||||
grid-column: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* serve's search */
|
||||
|
||||
form.search,
|
||||
input.filter {
|
||||
margin: 24px 0 0;
|
||||
}
|
||||
form.search input,
|
||||
input.filter {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
color: var(--foreground);
|
||||
background: var(--card);
|
||||
border: 1px solid var(--input);
|
||||
border-radius: var(--radius);
|
||||
padding: 9px 14px;
|
||||
}
|
||||
form.search input:focus,
|
||||
input.filter:focus {
|
||||
outline: 2px solid var(--blue);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.rows.results li {
|
||||
grid-template-columns: 52px minmax(0, 1fr);
|
||||
}
|
||||
.rows.results .meta {
|
||||
display: none;
|
||||
}
|
||||
.snippet {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
color: var(--muted-foreground);
|
||||
margin-top: 2px;
|
||||
}
|
||||
mark {
|
||||
background: var(--warn-bg);
|
||||
color: inherit;
|
||||
border-radius: 3px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
@@ -1,844 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""mechanics for a project's .notes/ dir, see ~/.config/claude/rules/notes.md
|
||||
|
||||
usage:
|
||||
notes.py [--root DIR] init create .notes/ with its git repo, if missing
|
||||
notes.py [--root DIR] next d|w|q print the next free id of a bucket
|
||||
notes.py [--root DIR] index regenerate index.html, notes.css, the markdown pages and
|
||||
the portable copies of other/out/ in _out/
|
||||
notes.py [--root DIR] commit SUBJECT regenerate the index, commit every change in .notes/
|
||||
notes.py serve [--port 8765] [--roots DIR ...]
|
||||
every project's notes on one port; roots default to
|
||||
$NOTES_ROOTS, else ~/git
|
||||
|
||||
DIR is the project root, by default the git toplevel of the current dir, else the current dir
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import html
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
BUCKETS = {"d": "docs", "w": "work", "q": "questions"}
|
||||
ID_RE = re.compile(r"^(docs|work|questions)/([dwq])(\d{3})-")
|
||||
README = (
|
||||
"Local notes, never pushed. Layout and rules: ~/.config/claude/rules/notes.md\n"
|
||||
)
|
||||
|
||||
|
||||
def git(notes: Path, *args: str, check: bool = True) -> str:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(notes), *args], check=check, capture_output=True, text=True
|
||||
).stdout
|
||||
|
||||
|
||||
def project_root(arg: str | None) -> Path:
|
||||
if arg:
|
||||
return Path(arg).resolve()
|
||||
out = subprocess.run(
|
||||
["git", "rev-parse", "--show-toplevel"],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
root = Path(out.stdout.strip()) if out.returncode == 0 else Path.cwd()
|
||||
# inside .notes the toplevel is the notes repo itself
|
||||
return root.parent if root.name == ".notes" else root
|
||||
|
||||
|
||||
def init(notes: Path) -> None:
|
||||
for d in ("docs", "work", "questions", "other/in", "other/out"):
|
||||
(notes / d).mkdir(parents=True, exist_ok=True)
|
||||
if not (notes / "README.md").exists():
|
||||
(notes / "README.md").write_text(README)
|
||||
if not (notes / ".gitignore").exists():
|
||||
(notes / ".gitignore").write_text("/index.html\n")
|
||||
if not (notes / ".git").exists():
|
||||
git(notes, "init", "-q", "-b", "main")
|
||||
# NOTE:(@janezicmatej) local-only repo, any identity works when none is configured
|
||||
if not git(notes, "config", "user.email", check=False).strip():
|
||||
git(notes, "config", "user.name", "notes")
|
||||
git(notes, "config", "user.email", "notes@localhost")
|
||||
commit(notes, "init notes")
|
||||
else:
|
||||
write_index(notes)
|
||||
print(notes)
|
||||
|
||||
|
||||
def next_id(notes: Path, letter: str) -> str:
|
||||
bucket = BUCKETS[letter]
|
||||
paths = [f"{bucket}/{p.name}" for p in (notes / bucket).glob("*")]
|
||||
if (notes / ".git").exists():
|
||||
log = git(notes, "log", "--all", "--name-only", "--pretty=format:", check=False)
|
||||
paths += log.splitlines()
|
||||
nums = [int(m[3]) for p in paths if (m := ID_RE.match(p)) and m[2] == letter]
|
||||
return f"{letter}{max(nums, default=0) + 1:03d}"
|
||||
|
||||
|
||||
def commit(notes: Path, subject: str) -> None:
|
||||
write_index(notes)
|
||||
git(notes, "add", "-A")
|
||||
if git(notes, "status", "--porcelain").strip():
|
||||
git(notes, "commit", "-q", "--no-gpg-sign", "-m", subject)
|
||||
print(git(notes, "log", "-1", "--oneline").strip())
|
||||
else:
|
||||
print("nothing to commit")
|
||||
|
||||
|
||||
# pages: index.html, notes.css, _pages/ and _out/ are generated, never committed
|
||||
|
||||
CSS = Path(__file__).with_name("notes.css")
|
||||
GENERATED = ("/index.html", "/notes.css", "/_pages/", "/_out/")
|
||||
PAGES = "_pages"
|
||||
OUT = "_out"
|
||||
CSS_LINK = re.compile(r'<link\b[^>]*href="[^"]*notes\.css"[^>]*>', re.IGNORECASE)
|
||||
BACK_NAV = re.compile(r'\s*<nav class="back">.*?</nav>', re.DOTALL | re.IGNORECASE)
|
||||
ID_TOKEN = re.compile(r"(?<![\w/.-])([dwq]\d{3})(?![\w/-])")
|
||||
|
||||
|
||||
def ensure_gitignore(notes: Path) -> None:
|
||||
path = notes / ".gitignore"
|
||||
lines = path.read_text().splitlines() if path.exists() else []
|
||||
missing = [g for g in GENERATED if g not in lines]
|
||||
if missing:
|
||||
path.write_text("\n".join([*lines, *missing]) + "\n")
|
||||
|
||||
|
||||
def frontmatter(text: str) -> tuple[dict[str, str], str]:
|
||||
m = re.match(r"^---\n(.*?)\n---\n?", text, re.DOTALL)
|
||||
if not m:
|
||||
return {}, text
|
||||
meta = {}
|
||||
for line in m[1].splitlines():
|
||||
key, sep, value = line.partition(":")
|
||||
if sep:
|
||||
meta[key.strip()] = value.strip()
|
||||
return meta, text[m.end() :]
|
||||
|
||||
|
||||
def split_title(body: str, fallback: str) -> tuple[str, str]:
|
||||
"""first heading as the title, without a leading id, and the body without it"""
|
||||
m = re.search(r"^#\s+(.+)\n?", body, re.MULTILINE)
|
||||
if not m:
|
||||
return fallback, body
|
||||
title = re.sub(r"^[dwq]\d{3}\b\s*", "", m[1].strip())
|
||||
return title, body[: m.start()] + body[m.end() :]
|
||||
|
||||
|
||||
def strip_tags(s: str) -> str:
|
||||
return html.unescape(re.sub(r"<[^>]+>", "", s)).strip()
|
||||
|
||||
|
||||
class Site:
|
||||
"""what exists in .notes/ and where each item's page is, relative to .notes/"""
|
||||
|
||||
def __init__(self, notes: Path) -> None:
|
||||
self.notes = notes
|
||||
self.docs = self._docs()
|
||||
self.work = sorted(p for p in (notes / "work").glob("w[0-9]*") if p.is_dir())
|
||||
self.questions = sorted((notes / "questions").glob("q[0-9]*.md"))
|
||||
self.other = {
|
||||
sub: sorted(f for f in (notes / "other" / sub).rglob("*") if f.is_file())
|
||||
for sub in ("in", "out")
|
||||
if (notes / "other" / sub).exists()
|
||||
}
|
||||
self.urls: dict[str, str] = {}
|
||||
for d in self.docs:
|
||||
self.urls[d["id"]] = d["page"]
|
||||
for w in self.work:
|
||||
self.urls[w.name[:4]] = self.page_of(w / "brief.md")
|
||||
for q in self.questions:
|
||||
self.urls[q.name[:4]] = self.page_of(q)
|
||||
|
||||
def _docs(self) -> list[dict]:
|
||||
out = []
|
||||
for p in sorted((self.notes / "docs").glob("d[0-9]*")):
|
||||
page = p / "index.html" if p.is_dir() else p
|
||||
if not page.is_file() or page.suffix != ".html":
|
||||
continue
|
||||
text = page.read_text(errors="replace")
|
||||
title = re.search(r"<title>(.*?)</title>", text, re.DOTALL | re.IGNORECASE)
|
||||
status = re.search(
|
||||
r"<p[^>]*class=\"[^\"]*\bstatus\b[^\"]*\"[^>]*>(.*?)</p>",
|
||||
text,
|
||||
re.DOTALL | re.IGNORECASE,
|
||||
)
|
||||
status_text = re.sub(
|
||||
r"^status:\s*", "", strip_tags(status[1]) if status else ""
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
"id": p.name[:4],
|
||||
"title": strip_tags(title[1]) if title else p.stem,
|
||||
"page": page.relative_to(self.notes).as_posix(),
|
||||
"status": status_text,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
def page_of(self, src: Path) -> str:
|
||||
"""where a markdown file's rendered page lives, relative to .notes/"""
|
||||
rel = src.relative_to(self.notes).with_suffix(".html")
|
||||
return f"{PAGES}/{rel.as_posix()}"
|
||||
|
||||
def href(self, target: str, from_page: str) -> str:
|
||||
"""a link from one page to another, both relative to .notes/"""
|
||||
base = Path(from_page).parent
|
||||
return quote(Path(*([".."] * len(base.parts)), target).as_posix())
|
||||
|
||||
|
||||
def status_tag(status: str) -> str:
|
||||
e = html.escape
|
||||
if status.startswith("current"):
|
||||
return f'<span class="tag good" title="{e(status)}">current</span>'
|
||||
if status.startswith("partly stale"):
|
||||
return f'<span class="tag warn" title="{e(status)}">stale</span>'
|
||||
if m := re.match(r"superseded by (d\d{3})", status):
|
||||
return f'<span class="tag" title="{e(status)}">superseded by {m[1]}</span>'
|
||||
return '<span class="tag bad">no status line</span>'
|
||||
|
||||
|
||||
def page(site: Site, path: str, title: str, body: str) -> str:
|
||||
css = site.href("notes.css", path)
|
||||
return (
|
||||
'<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n'
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
||||
f"<title>{html.escape(title)}</title>\n"
|
||||
f'<link rel="stylesheet" href="{css}">\n</head>\n'
|
||||
f'<body>\n<main class="page">\n{body}\n</main>\n</body>\n</html>\n'
|
||||
)
|
||||
|
||||
|
||||
# markdown, the small subset notes are written in
|
||||
|
||||
|
||||
def inline(text: str, site: Site, at: str) -> str:
|
||||
out, last = [], 0
|
||||
for m in re.finditer(r"`([^`]+)`", text):
|
||||
out.append(inline_plain(text[last : m.start()], site, at))
|
||||
out.append(f"<code>{html.escape(m[1])}</code>")
|
||||
last = m.end()
|
||||
out.append(inline_plain(text[last:], site, at))
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def inline_plain(text: str, site: Site, at: str) -> str:
|
||||
links: list[str] = []
|
||||
|
||||
def keep(s: str) -> str:
|
||||
links.append(s)
|
||||
return f"\x00{len(links) - 1}\x00"
|
||||
|
||||
text = re.sub(
|
||||
r"\[([^\]]+)\]\(([^)\s]+)\)",
|
||||
lambda m: keep(f'<a href="{html.escape(m[2])}">{html.escape(m[1])}</a>'),
|
||||
text,
|
||||
)
|
||||
text = html.escape(text, quote=False)
|
||||
text = re.sub(r"\*\*(.+?)\*\*", r"<strong>\1</strong>", text)
|
||||
|
||||
def id_link(m: re.Match) -> str:
|
||||
url = site.urls.get(m[1])
|
||||
return f'<a href="{site.href(url, at)}">{m[1]}</a>' if url else m[1]
|
||||
|
||||
text = ID_TOKEN.sub(id_link, text)
|
||||
return re.sub(r"\x00(\d+)\x00", lambda m: links[int(m[1])], text)
|
||||
|
||||
|
||||
# gfm pipe tables: a header row, a row of dashes, then body rows up to a line without a pipe
|
||||
PIPE = re.compile(r"(?<!\\)\|")
|
||||
SEPARATOR_CELL = re.compile(r"^:?-+:?$")
|
||||
ALIGN = {(True, True): "center", (False, True): "right", (True, False): "left"}
|
||||
|
||||
|
||||
def table_cells(line: str) -> list[str]:
|
||||
s = line.strip()
|
||||
s = s.removeprefix("|")
|
||||
s = s[:-1] if s.endswith("|") and not s.endswith("\\|") else s
|
||||
return [c.strip().replace("\\|", "|") for c in PIPE.split(s)]
|
||||
|
||||
|
||||
def table_start(lines: list[str], i: int) -> list[str] | None:
|
||||
"""the column alignments if a table starts at lines[i], else None"""
|
||||
if i + 1 >= len(lines) or not (PIPE.search(lines[i]) and PIPE.search(lines[i + 1])):
|
||||
return None
|
||||
seps = table_cells(lines[i + 1])
|
||||
if len(table_cells(lines[i])) != len(seps):
|
||||
return None
|
||||
if not all(SEPARATOR_CELL.match(c) for c in seps):
|
||||
return None
|
||||
return [ALIGN.get((c[0] == ":", c[-1] == ":"), "") for c in seps]
|
||||
|
||||
|
||||
def table_row(line: str, aligns: list[str], tag: str, site: Site, at: str) -> str:
|
||||
# short rows are padded, extra cells dropped, as gfm does
|
||||
cells = (table_cells(line) + [""] * len(aligns))[: len(aligns)]
|
||||
row = []
|
||||
for cell, align in zip(cells, aligns):
|
||||
style = f' style="text-align: {align}"' if align else ""
|
||||
row.append(f"<{tag}{style}>{inline(cell, site, at)}</{tag}>")
|
||||
return f"<tr>{''.join(row)}</tr>"
|
||||
|
||||
|
||||
def markdown(text: str, site: Site, at: str) -> str:
|
||||
lines = text.splitlines()
|
||||
out: list[str] = []
|
||||
para: list[str] = []
|
||||
# open lists as (indent, tag)
|
||||
lists: list[tuple[int, str]] = []
|
||||
item_open = False
|
||||
|
||||
def flush_para() -> None:
|
||||
if para:
|
||||
out.append(f"<p>{inline(' '.join(para), site, at)}</p>")
|
||||
para.clear()
|
||||
|
||||
def close_lists(to: int = -1) -> None:
|
||||
nonlocal item_open
|
||||
while lists and lists[-1][0] > to:
|
||||
out.append(f"</li></{lists.pop()[1]}>")
|
||||
item_open = bool(lists)
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
if line.lstrip().startswith("```"):
|
||||
flush_para()
|
||||
close_lists()
|
||||
indent = len(line) - len(line.lstrip())
|
||||
code = []
|
||||
i += 1
|
||||
while i < len(lines) and not lines[i].lstrip().startswith("```"):
|
||||
code.append(lines[i][indent:])
|
||||
i += 1
|
||||
out.append(f"<pre><code>{html.escape(chr(10).join(code))}</code></pre>")
|
||||
i += 1
|
||||
continue
|
||||
if not line.strip():
|
||||
flush_para()
|
||||
i += 1
|
||||
continue
|
||||
if m := re.match(r"^(#{1,4})\s+(.*)$", line):
|
||||
flush_para()
|
||||
close_lists()
|
||||
level = min(len(m[1]) + 1, 4)
|
||||
out.append(f"<h{level}>{inline(m[2], site, at)}</h{level}>")
|
||||
i += 1
|
||||
continue
|
||||
if aligns := table_start(lines, i):
|
||||
flush_para()
|
||||
close_lists()
|
||||
head = table_row(line, aligns, "th", site, at)
|
||||
i += 2
|
||||
body = []
|
||||
while i < len(lines) and PIPE.search(lines[i]):
|
||||
body.append(table_row(lines[i], aligns, "td", site, at))
|
||||
i += 1
|
||||
tbody = f"<tbody>{''.join(body)}</tbody>" if body else ""
|
||||
out.append(
|
||||
f'<div class="table"><table><thead>{head}</thead>{tbody}</table></div>'
|
||||
)
|
||||
continue
|
||||
if m := re.match(r"^(\s*)([-*]|\d+\.)\s+(.*)$", line):
|
||||
flush_para()
|
||||
indent, tag = len(m[1]), "ol" if m[2][0].isdigit() else "ul"
|
||||
if not lists or indent > lists[-1][0]:
|
||||
out.append(f"<{tag}><li>")
|
||||
lists.append((indent, tag))
|
||||
else:
|
||||
close_lists(indent)
|
||||
# the other kind of list at the same indent ends this one and starts its own
|
||||
if lists and lists[-1][0] == indent and lists[-1][1] != tag:
|
||||
close_lists(indent - 1)
|
||||
if lists and lists[-1][0] == indent:
|
||||
out.append("</li><li>")
|
||||
else:
|
||||
out.append(f"<{tag}><li>")
|
||||
lists.append((indent, tag))
|
||||
item = [m[3]]
|
||||
i += 1
|
||||
# continuation lines belong to the item
|
||||
while (
|
||||
i < len(lines)
|
||||
and lines[i].strip()
|
||||
and not re.match(r"^\s*([-*]|\d+\.)\s+|^\s*```|^#{1,4}\s", lines[i])
|
||||
and not table_start(lines, i)
|
||||
):
|
||||
item.append(lines[i].strip())
|
||||
i += 1
|
||||
out.append(inline(" ".join(item), site, at))
|
||||
continue
|
||||
if lists and line.startswith(" "):
|
||||
out.append(" " + inline(line.strip(), site, at))
|
||||
i += 1
|
||||
continue
|
||||
close_lists()
|
||||
para.append(line.strip())
|
||||
i += 1
|
||||
flush_para()
|
||||
close_lists()
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def render_md(site: Site, src: Path, kind: str) -> None:
|
||||
at = site.page_of(src)
|
||||
meta, body = frontmatter(src.read_text(errors="replace"))
|
||||
title, body = split_title(body, src.stem)
|
||||
e = html.escape
|
||||
item_id = next((p[:4] for p in src.parts if re.match(r"[dwq]\d{3}-", p)), "")
|
||||
eyebrow = " · ".join(x for x in (item_id, kind) if x)
|
||||
parts = [
|
||||
f'<nav class="back"><a href="{site.href("index.html", at)}">← notes</a></nav>',
|
||||
"<header>",
|
||||
f'<p class="eyebrow">{e(eyebrow)}</p>' if eyebrow else "",
|
||||
f"<h1>{e(title)}</h1>",
|
||||
]
|
||||
if meta:
|
||||
dl = "".join(
|
||||
f"<div><dt>{e(k)}</dt><dd>{inline(v, site, at)}</dd></div>"
|
||||
for k, v in meta.items()
|
||||
)
|
||||
parts.append(f'<dl class="md-meta">{dl}</dl>')
|
||||
parts += ["</header>", markdown(body, site, at)]
|
||||
if kind == "work":
|
||||
files = []
|
||||
for f in sorted(src.parent.rglob("*")):
|
||||
if not f.is_file() or f == src:
|
||||
continue
|
||||
target = site.page_of(f) if f.suffix == ".md" else f.relative_to(site.notes)
|
||||
files.append(f'<a href="{site.href(str(target), at)}">{e(f.name)}</a>')
|
||||
if files:
|
||||
parts.append(f'<p class="files">files: {" · ".join(files)}</p>')
|
||||
out = site.notes / at
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(page(site, at, title, "\n".join(p for p in parts if p)))
|
||||
|
||||
|
||||
def clear(d: Path) -> None:
|
||||
if d.exists():
|
||||
for f in sorted(d.rglob("*"), reverse=True):
|
||||
f.unlink() if f.is_file() else f.rmdir()
|
||||
|
||||
|
||||
def write_portable(site: Site) -> None:
|
||||
"""other/out/ as it leaves .notes/: css inlined, no back link to an index it won't have"""
|
||||
out = site.notes / OUT
|
||||
clear(out)
|
||||
src = site.notes / "other" / "out"
|
||||
style = f"<style>\n{CSS.read_text()}</style>"
|
||||
for f in site.other.get("out", []):
|
||||
target = out / f.relative_to(src)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
if f.suffix != ".html":
|
||||
target.write_bytes(f.read_bytes())
|
||||
continue
|
||||
# the nav goes before the css comes in: its header comment shows a nav too
|
||||
text = BACK_NAV.sub("", f.read_text(errors="replace"), count=1)
|
||||
target.write_text(CSS_LINK.sub(lambda _: style, text, count=1))
|
||||
|
||||
|
||||
def write_index(notes: Path) -> None:
|
||||
ensure_gitignore(notes)
|
||||
(notes / "notes.css").write_text(CSS.read_text())
|
||||
site = Site(notes)
|
||||
|
||||
clear(notes / PAGES)
|
||||
write_portable(site)
|
||||
for q in site.questions:
|
||||
render_md(site, q, "question")
|
||||
for w in site.work:
|
||||
for f in sorted(w.rglob("*.md")):
|
||||
render_md(site, f, "work")
|
||||
for files in site.other.values():
|
||||
for f in files:
|
||||
if f.suffix == ".md":
|
||||
render_md(site, f, "other")
|
||||
|
||||
e = html.escape
|
||||
|
||||
def section(name: str, rows: list[str], empty: str, plain: bool = False) -> str:
|
||||
head = f'<h2>{name} <span class="count">{len(rows)}</span></h2>'
|
||||
if not rows:
|
||||
return f'{head}<p class="empty">{empty}</p>'
|
||||
cls = "rows plain" if plain else "rows"
|
||||
return f'{head}<ul class="{cls}">{"".join(rows)}</ul>'
|
||||
|
||||
def row(item_id: str, href: str, title: str, meta: str = "", cls: str = "") -> str:
|
||||
return (
|
||||
f'<li><span class="id">{e(item_id)}</span>'
|
||||
f'<span class="title {cls}"><a href="{href}">{e(title)}</a></span>'
|
||||
f'<span class="meta">{meta}</span></li>'
|
||||
)
|
||||
|
||||
docs = [
|
||||
row(
|
||||
d["id"],
|
||||
quote(d["page"]),
|
||||
d["title"],
|
||||
status_tag(d["status"]),
|
||||
"superseded" if d["status"].startswith("superseded") else "",
|
||||
)
|
||||
for d in site.docs
|
||||
]
|
||||
work = []
|
||||
for w in site.work:
|
||||
brief = w / "brief.md"
|
||||
title = w.name[5:]
|
||||
if brief.exists():
|
||||
title, _ = split_title(frontmatter(brief.read_text())[1], title)
|
||||
work.append(row(w.name[:4], quote(site.urls[w.name[:4]]), title))
|
||||
questions = []
|
||||
for q in site.questions:
|
||||
meta, body = frontmatter(q.read_text(errors="replace"))
|
||||
title, _ = split_title(body, q.stem)
|
||||
created = e(meta.get("created", ""))
|
||||
questions.append(row(q.name[:4], quote(site.page_of(q)), title, created))
|
||||
|
||||
other = []
|
||||
for sub, files in site.other.items():
|
||||
for f in files:
|
||||
target = site.page_of(f) if f.suffix == ".md" else f.relative_to(notes)
|
||||
portable = ""
|
||||
if sub == "out" and f.suffix == ".html":
|
||||
rel = f.relative_to(notes / "other" / "out").as_posix()
|
||||
portable = (
|
||||
f' <a class="tag info" href="{quote(f"{OUT}/{rel}")}">to send</a>'
|
||||
)
|
||||
other.append(
|
||||
f'<li><span class="title"><a href="{quote(str(target))}">{e(f.name)}</a>'
|
||||
f' <span class="tag">{sub}</span>{portable}</span></li>'
|
||||
)
|
||||
|
||||
project = notes.parent.name
|
||||
body = "\n".join(
|
||||
[
|
||||
# under serve this is the hub, under a plain http.server it is this index
|
||||
'<nav class="back"><a href="/">← all notes</a></nav>',
|
||||
'<header class="index-head">',
|
||||
f"<h1>{e(project)} notes</h1>",
|
||||
(
|
||||
f"<p>{len(site.docs)} docs · {len(site.work)} in flight · "
|
||||
f"{len(site.questions)} open questions · generated {datetime.now().astimezone().date()}</p>"
|
||||
),
|
||||
"</header>",
|
||||
'<div class="index">',
|
||||
section("Docs", docs, "no docs"),
|
||||
section("Work in flight", work, "nothing in flight"),
|
||||
section("Open questions", questions, "no open questions"),
|
||||
section("Other", other, "nothing here", plain=True) if other else "",
|
||||
"</div>",
|
||||
]
|
||||
)
|
||||
(notes / "index.html").write_text(
|
||||
page(site, "index.html", f"{project} notes", body)
|
||||
)
|
||||
|
||||
|
||||
# serve: every project's notes on one port
|
||||
|
||||
|
||||
SKIP_DIRS = {"node_modules", "target", "dist", "build", "vendor", "__pycache__"}
|
||||
|
||||
|
||||
def find_notes(roots: list[Path], depth: int = 6) -> dict[str, Path]:
|
||||
"""url prefix (path relative to its root) -> .notes dir, for every project under roots"""
|
||||
found: dict[str, Path] = {}
|
||||
|
||||
def walk(d: Path, root: Path, left: int) -> None:
|
||||
notes = d / ".notes"
|
||||
if notes.is_dir() and (notes / "docs").is_dir():
|
||||
# a root that is itself a project goes by its name, not "."
|
||||
found[d.name if d == root else d.relative_to(root).as_posix()] = notes
|
||||
# a project's subdirectories are its code, even when the project is a root
|
||||
return
|
||||
# a repo's own subdirectories are its code, not more projects
|
||||
if left == 0 or ((d / ".git").exists() and d != root):
|
||||
return
|
||||
try:
|
||||
children = sorted(d.iterdir())
|
||||
except OSError:
|
||||
return
|
||||
for c in children:
|
||||
skip = c.name.startswith(".") or c.name in SKIP_DIRS
|
||||
if c.is_dir() and not c.is_symlink() and not skip:
|
||||
walk(c, root, left - 1)
|
||||
|
||||
for root in roots:
|
||||
if root.is_dir():
|
||||
walk(root, root, depth)
|
||||
return found
|
||||
|
||||
|
||||
def hub_page(title: str, body: str) -> str:
|
||||
return (
|
||||
'<!doctype html>\n<html lang="en">\n<head>\n<meta charset="utf-8">\n'
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
||||
f"<title>{html.escape(title)}</title>\n"
|
||||
'<link rel="stylesheet" href="/notes.css">\n</head>\n'
|
||||
f'<body>\n<main class="page">\n{body}\n</main>\n</body>\n</html>\n'
|
||||
)
|
||||
|
||||
|
||||
def search_form(action: str, q: str = "") -> str:
|
||||
return (
|
||||
f'<form class="search" action="{action}" role="search">'
|
||||
f'<input type="search" name="q" value="{html.escape(q)}" '
|
||||
'placeholder="search this project\'s notes" aria-label="search notes">'
|
||||
"</form>"
|
||||
)
|
||||
|
||||
|
||||
# filters the hub's project rows as you type, nothing leaves the page
|
||||
FILTER_BOX = (
|
||||
'<input class="filter" type="search" placeholder="filter projects" '
|
||||
'aria-label="filter projects" autofocus>'
|
||||
)
|
||||
FILTER_SCRIPT = """<script>
|
||||
const box = document.querySelector("input.filter");
|
||||
const rows = [...document.querySelectorAll(".rows.hub li")];
|
||||
box.addEventListener("input", () => {
|
||||
const words = box.value.toLowerCase().split(/\\s+/).filter(Boolean);
|
||||
for (const r of rows) {
|
||||
const name = r.dataset.name;
|
||||
r.hidden = !words.every((w) => name.includes(w));
|
||||
}
|
||||
document.querySelector(".hub-empty").hidden = rows.some((r) => !r.hidden);
|
||||
});
|
||||
box.addEventListener("keydown", (e) => {
|
||||
const first = rows.find((r) => !r.hidden);
|
||||
if (e.key === "Enter" && first) location.href = first.querySelector("a").href;
|
||||
});
|
||||
</script>"""
|
||||
|
||||
|
||||
def hub(projects: dict[str, Path]) -> str:
|
||||
e = html.escape
|
||||
rows = []
|
||||
for prefix, notes in sorted(projects.items()):
|
||||
questions = len(list((notes / "questions").glob("q[0-9]*.md")))
|
||||
work = len([p for p in (notes / "work").glob("w[0-9]*") if p.is_dir()])
|
||||
docs = len(list((notes / "docs").glob("d[0-9]*")))
|
||||
last = git(notes, "log", "-1", "--format=%cs", check=False).strip()
|
||||
meta = f"{docs} docs · {work} in flight · {questions} open · {last or 'no commits'}"
|
||||
rows.append(
|
||||
f'<li data-name="{e(prefix.lower())}">'
|
||||
f'<span class="title"><a href="/{quote(prefix)}/">{e(prefix)}</a></span>'
|
||||
f'<span class="meta">{meta}</span></li>'
|
||||
)
|
||||
body = (
|
||||
'<header class="index-head"><h1>notes</h1>'
|
||||
f"<p>{len(projects)} projects · generated {datetime.now().astimezone().date()}</p></header>"
|
||||
+ (FILTER_BOX if rows else "")
|
||||
+ '<div class="index"><h2>Projects</h2>'
|
||||
+ (
|
||||
f'<ul class="rows hub">{"".join(rows)}</ul>'
|
||||
'<p class="empty hub-empty" hidden>no project matches</p>'
|
||||
if rows
|
||||
else '<p class="empty">no .notes/ found</p>'
|
||||
)
|
||||
+ "</div>"
|
||||
# after the rows, so the script finds them
|
||||
+ (FILTER_SCRIPT if rows else "")
|
||||
)
|
||||
return hub_page("notes", body)
|
||||
|
||||
|
||||
def page_text(page: str) -> str:
|
||||
"""the readable text of an html page, without its chrome, words kept apart"""
|
||||
flags = re.DOTALL | re.IGNORECASE
|
||||
page = re.sub(r"<(style|script|svg|nav|title)\b.*?</\1>", " ", page, flags=flags)
|
||||
return html.unescape(re.sub(r"<[^>]+>", " ", page))
|
||||
|
||||
|
||||
def searchable(notes: Path) -> list[tuple[str, str, str, str]]:
|
||||
"""(id, title, url relative to .notes/, plain text) for every page worth searching"""
|
||||
site = Site(notes)
|
||||
out = []
|
||||
for d in site.docs:
|
||||
text = (notes / d["page"]).read_text(errors="replace")
|
||||
out.append((d["id"], d["title"], d["page"], page_text(text)))
|
||||
mds = [*site.questions, *(f for w in site.work for f in sorted(w.rglob("*.md")))]
|
||||
mds += [f for files in site.other.values() for f in files if f.suffix == ".md"]
|
||||
for f in mds:
|
||||
_, body = frontmatter(f.read_text(errors="replace"))
|
||||
title, _ = split_title(body, f.stem)
|
||||
item = next((p[:4] for p in f.parts if re.match(r"[dwq]\d{3}-", p)), "")
|
||||
out.append((item, title, site.page_of(f), body))
|
||||
for files in site.other.values():
|
||||
for f in files:
|
||||
if f.suffix == ".html":
|
||||
text = page_text(f.read_text(errors="replace"))
|
||||
out.append(("", f.name, f.relative_to(notes).as_posix(), text))
|
||||
return out
|
||||
|
||||
|
||||
def search(prefix: str, notes: Path, q: str) -> str:
|
||||
e = html.escape
|
||||
terms = [t.lower() for t in q.split() if t]
|
||||
hits = []
|
||||
for item, title, url, text in searchable(notes) if terms else []:
|
||||
low_title, low = title.lower(), " ".join(text.split()).lower()
|
||||
if not all(t in low_title or t in low for t in terms):
|
||||
continue
|
||||
flat = " ".join(text.split())
|
||||
# centre on the whole phrase if it occurs, else on the rarest word
|
||||
at = low.find(" ".join(terms))
|
||||
if at < 0:
|
||||
rarest = min((t for t in terms if t in low), key=low.count, default="")
|
||||
at = max(low.find(rarest), 0) if rarest else 0
|
||||
start = max(0, at - 80)
|
||||
snippet = e(("…" if start else "") + flat[start : at + 160] + "…")
|
||||
for t in sorted(set(terms), key=len, reverse=True):
|
||||
snippet = re.sub(
|
||||
f"({re.escape(e(t))})", r"<mark>\1</mark>", snippet, flags=re.IGNORECASE
|
||||
)
|
||||
score = sum(t in low_title for t in terms) * 100 + sum(
|
||||
low.count(t) for t in terms
|
||||
)
|
||||
hits.append((score, item, title, url, snippet))
|
||||
write_index(notes)
|
||||
hits.sort(key=lambda h: -h[0])
|
||||
rows = "".join(
|
||||
f'<li><span class="id">{e(item)}</span><span class="title">'
|
||||
f'<a href="{quote(url)}">{e(title)}</a>'
|
||||
f'<span class="snippet">{snippet}</span></span></li>'
|
||||
for _, item, title, url, snippet in hits
|
||||
)
|
||||
found = (
|
||||
f'<ul class="rows results">{rows}</ul>'
|
||||
if rows
|
||||
else '<p class="empty">nothing found</p>'
|
||||
)
|
||||
body = (
|
||||
'<nav class="back"><a href="./">← notes</a></nav>'
|
||||
f'<header class="index-head"><h1>{e(prefix)}: search</h1>'
|
||||
f"<p>{len(hits)} results for “{e(q)}”</p></header>"
|
||||
+ search_form("search", q)
|
||||
+ f'<div class="index">{found}</div>'
|
||||
)
|
||||
return hub_page(f"{q} · {prefix} notes", body)
|
||||
|
||||
|
||||
def serve(roots: list[Path], host: str, port: int) -> None:
|
||||
import http.server
|
||||
from urllib.parse import parse_qs, unquote, urlsplit
|
||||
|
||||
class Handler(http.server.SimpleHTTPRequestHandler):
|
||||
def guess_type(self, path: str) -> str:
|
||||
if str(path).endswith(".md"):
|
||||
return "text/plain; charset=utf-8"
|
||||
return super().guess_type(path)
|
||||
|
||||
def send_text(self, body: str, ctype: str) -> None:
|
||||
data = body.encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", ctype)
|
||||
self.send_header("Content-Length", str(len(data)))
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(data)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = unquote(urlsplit(self.path).path)
|
||||
if path in ("/", "/index.html"):
|
||||
return self.send_text(
|
||||
hub(find_notes(roots)), "text/html; charset=utf-8"
|
||||
)
|
||||
if path == "/notes.css":
|
||||
return self.send_text(CSS.read_text(), "text/css; charset=utf-8")
|
||||
projects = find_notes(roots)
|
||||
prefix = max(
|
||||
(p for p in projects if path == f"/{p}" or path.startswith(f"/{p}/")),
|
||||
key=len,
|
||||
default=None,
|
||||
)
|
||||
if prefix is None:
|
||||
return self.send_error(404, "no such project")
|
||||
if path == f"/{prefix}":
|
||||
self.send_response(301)
|
||||
self.send_header("Location", f"/{quote(prefix)}/")
|
||||
self.end_headers()
|
||||
return None
|
||||
rest = path[len(prefix) + 2 :]
|
||||
notes = projects[prefix]
|
||||
if rest == "search":
|
||||
q = parse_qs(urlsplit(self.path).query).get("q", [""])[0]
|
||||
body = search(prefix, notes, q)
|
||||
return self.send_text(body, "text/html; charset=utf-8")
|
||||
if rest in ("", "index.html"):
|
||||
# rebuilt on every visit, so a session that forgot to regenerate never shows stale;
|
||||
# the search box only works under serve, so it is added here, not in the file
|
||||
write_index(notes)
|
||||
page = (notes / "index.html").read_text()
|
||||
page = page.replace("</header>", "</header>" + search_form("search"), 1)
|
||||
return self.send_text(page, "text/html; charset=utf-8")
|
||||
self.serve_from = projects[prefix]
|
||||
self.path = "/" + rest
|
||||
return super().do_GET()
|
||||
|
||||
def translate_path(self, path: str) -> str:
|
||||
base = self.serve_from.resolve()
|
||||
target = (base / unquote(urlsplit(path).path).lstrip("/")).resolve()
|
||||
return str(target if target.is_relative_to(base) else base / "__outside__")
|
||||
|
||||
def log_message(self, format: str, *args) -> None:
|
||||
pass
|
||||
|
||||
server = http.server.ThreadingHTTPServer((host, port), Handler)
|
||||
print(f"serving notes from {', '.join(map(str, roots))} on http://{host}:{port}/")
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
pass
|
||||
|
||||
|
||||
def default_roots() -> list[Path]:
|
||||
import os
|
||||
|
||||
env = os.environ.get("NOTES_ROOTS")
|
||||
if env:
|
||||
return [Path(p).expanduser() for p in env.split(os.pathsep) if p]
|
||||
return [Path.home() / "git"]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("--root")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
sub.add_parser("init")
|
||||
p_next = sub.add_parser("next")
|
||||
p_next.add_argument("letter", choices=sorted(BUCKETS))
|
||||
sub.add_parser("index")
|
||||
p_commit = sub.add_parser("commit")
|
||||
p_commit.add_argument("subject")
|
||||
p_serve = sub.add_parser("serve")
|
||||
p_serve.add_argument("--port", type=int, default=8765)
|
||||
p_serve.add_argument("--host", default="127.0.0.1")
|
||||
p_serve.add_argument("--roots", nargs="*", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.cmd == "serve":
|
||||
serve(args.roots or default_roots(), args.host, args.port)
|
||||
return
|
||||
|
||||
notes = project_root(args.root) / ".notes"
|
||||
if args.cmd == "init":
|
||||
init(notes)
|
||||
return
|
||||
if not notes.exists():
|
||||
sys.exit(f"{notes} does not exist, run init first")
|
||||
if args.cmd == "next":
|
||||
print(next_id(notes, args.letter))
|
||||
elif args.cmd == "index":
|
||||
write_index(notes)
|
||||
print(notes / "index.html")
|
||||
elif args.cmd == "commit":
|
||||
commit(notes, args.subject)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,158 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# NOTE:(@janezicmatej) layout adapted from github.com/nejcr/grind statusline.js:
|
||||
# model + context, repo, 5h/7d limits, session id, optional dev-links row
|
||||
|
||||
input=$(cat)
|
||||
|
||||
# one jq call emits every field as a quoted shell assignment
|
||||
eval "$(jq -r '
|
||||
def s: tostring | @sh;
|
||||
"model=\(.model.display_name // "" | s)",
|
||||
"used=\(.context_window.total_input_tokens // 0 | s)",
|
||||
"total=\(.context_window.context_window_size // 0 | s)",
|
||||
"ctx_pct=\(.context_window.used_percentage // "" | s)",
|
||||
"cwd=\(.workspace.current_dir // "" | s)",
|
||||
"project_dir=\(.workspace.project_dir // "" | s)",
|
||||
"session_id=\(.session_id // "" | s)"
|
||||
' <<<"$input")"
|
||||
|
||||
# claude only sends rate_limits after the first api response, so a fresh session
|
||||
# would show none until its first prompt; every refresh that has them writes the
|
||||
# last known limits next to the config, and a fresh session reads those back dimmed
|
||||
cfg_dir=${CLAUDE_CONFIG_DIR:-$HOME/.claude}
|
||||
limits_cache=$cfg_dir/statusline-limits.json
|
||||
read_limits() {
|
||||
jq -r '
|
||||
def s: tostring | @sh;
|
||||
"five_pct=\(.five_hour.used_percentage // "" | s)",
|
||||
"five_reset=\(.five_hour.resets_at // "" | s)",
|
||||
"seven_pct=\(.seven_day.used_percentage // "" | s)",
|
||||
"seven_reset=\(.seven_day.resets_at // "" | s)"
|
||||
' 2>/dev/null
|
||||
}
|
||||
current=$(jq -c '.rate_limits // {} | with_entries(select(.value.used_percentage != null))' <<<"$input")
|
||||
stale=""
|
||||
if [[ $current != "{}" ]]; then
|
||||
eval "$(read_limits <<<"$current")"
|
||||
# merged, not replaced: a turn carrying one window must not wipe the other;
|
||||
# written then renamed so a concurrent session never reads it half-written
|
||||
merged=$(jq -c --argjson cur "$current" '. * $cur' "$limits_cache" 2>/dev/null) || merged=$current
|
||||
tmp=$limits_cache.$$.tmp
|
||||
if { printf '%s\n' "$merged" > "$tmp"; } 2>/dev/null; then mv -f "$tmp" "$limits_cache" 2>/dev/null || rm -f "$tmp"; fi
|
||||
elif [[ -f $limits_cache ]]; then
|
||||
stale=1
|
||||
eval "$(read_limits < "$limits_cache")"
|
||||
fi
|
||||
|
||||
R=$'\e[0m' C=$'\e[36m' G=$'\e[32m' Y=$'\e[33m' RE=$'\e[31m' D=$'\e[2m'
|
||||
|
||||
# "Claude 3.5 Sonnet" -> "Sonnet 3.5", "Claude Fable 5.1" -> "Fable 5.1"
|
||||
model=$(sed -E -e 's/^Claude +([0-9.]+) +([A-Za-z0-9_]+)$/\2 \1/; t' -e 's/^Claude +//' <<<"${model:-unknown}")
|
||||
|
||||
# 61234 -> 61.2k, half-up rounding to match Math.round
|
||||
fmt() { awk -v n="$1" 'BEGIN { if (n >= 1000) printf "%.1fk", n / 1000; else printf "%d", n }'; }
|
||||
round() { awk -v x="$1" 'BEGIN { printf "%d", int(x + 0.5) }'; }
|
||||
|
||||
# same scale for context and limits: green under 50, yellow under 80, red above
|
||||
color_for() {
|
||||
if (( $1 >= 80 )); then printf %s "$RE"; elif (( $1 >= 50 )); then printf %s "$Y"; else printf %s "$G"; fi
|
||||
}
|
||||
|
||||
# repo: origin url from the host onwards (host/group/repo), else the folder name
|
||||
repo=""
|
||||
if url=$(git -C "$cwd" --no-optional-locks remote get-url origin 2>/dev/null); then
|
||||
repo=$(sed -E \
|
||||
-e 's#^[^@/]+@([^:/]+):(.+)$#\1/\2#; t done' \
|
||||
-e 's#^[a-z+]+://([^@/]+@)?([^/:]+)(:[0-9]+)?/(.+)$#\2/\4#I; t done' \
|
||||
-e 's/.*//' -e ':done' -e 's#/+$##; s/\.git$//' <<<"$url")
|
||||
fi
|
||||
if [[ -z $repo ]]; then
|
||||
top=$(git -C "$cwd" --no-optional-locks rev-parse --show-toplevel 2>/dev/null)
|
||||
repo=$(basename "${top:-${project_dir:-$cwd}}")
|
||||
fi
|
||||
|
||||
# limits, same shape as the context group: "5h 42% (15:36)" and "7d 81% (5d)"
|
||||
# the 5h window resets within hours so the clock answers "when", the 7d one is
|
||||
# far enough out that days are all that matters; a rolled-over window shows a dash
|
||||
now=$(date +%s)
|
||||
rl_part() {
|
||||
local label=$1 pct=$2 reset=${3%.*} mode=$4 p s left
|
||||
[[ -n $pct ]] || return 0
|
||||
s=$(( ${reset:-0} - now ))
|
||||
if [[ -z $reset ]] || (( s <= 0 )); then
|
||||
printf '%s%s —%s' "$D" "$label" "$R"
|
||||
return 0
|
||||
fi
|
||||
p=$(round "$pct")
|
||||
case $mode in
|
||||
clock) left=$(date -d "@$reset" +%H:%M) ;;
|
||||
days) if (( s < 86400 )); then left="$(( (s + 3599) / 3600 ))h"; else left="$(( (s + 43200) / 86400 ))d"; fi ;;
|
||||
esac
|
||||
# last known values from the cache render fully dim to say they may be behind
|
||||
local col; col=$(color_for "$p"); [[ -n $stale ]] && col=$D
|
||||
printf '%s%s%s %s%s%%%s %s(%s)%s' "$D" "$label" "$R" "$col" "$p" "$R" "$D" "$left" "$R"
|
||||
}
|
||||
|
||||
groups=()
|
||||
|
||||
session="${C}${model}${R} ${G}$(fmt "$used")/$(fmt "$total")${R}"
|
||||
if [[ -n $ctx_pct ]]; then
|
||||
p=$(round "$ctx_pct")
|
||||
session+=" ${D}(${R}$(color_for "$p")${p}%${R}${D})${R}"
|
||||
fi
|
||||
groups+=("$session")
|
||||
|
||||
[[ -n $repo ]] && groups+=("${C}${repo}${R}")
|
||||
|
||||
# limits are only sent once the session has talked to the api
|
||||
part=$(rl_part 5h "$five_pct" "$five_reset" clock); [[ -n $part ]] && groups+=("$part")
|
||||
part=$(rl_part 7d "$seven_pct" "$seven_reset" days); [[ -n $part ]] && groups+=("$part")
|
||||
|
||||
# full session id so it can be pasted into claude --resume, dim and last
|
||||
[[ -n $session_id ]] && groups+=("${D}${session_id}${R}")
|
||||
|
||||
sep=" ${D}│${R} "
|
||||
out=""
|
||||
for g in "${groups[@]}"; do out+="${out:+$sep}$g"; done
|
||||
printf '%s\n' "$out"
|
||||
|
||||
# pinned dev links, opt-in per project: .claude/dev-links.json adds a second row
|
||||
# a value is either "url" or {url, note}, note being a throwaway local credential
|
||||
link_file=""
|
||||
for d in "$project_dir" "$cwd"; do
|
||||
[[ -n $d && -f $d/.claude/dev-links.json ]] && { link_file=$d/.claude/dev-links.json; break; }
|
||||
done
|
||||
[[ -n $link_file ]] || exit 0
|
||||
|
||||
pins=$(jq -r '
|
||||
to_entries[]
|
||||
| { label: .key,
|
||||
url: (if (.value | type) == "string" then .value
|
||||
elif (.value | type) == "object" then .value.url else null end),
|
||||
note: (if (.value | type) == "object" and (.value.note | type) == "string" then .value.note else "" end) }
|
||||
| select((.url | type) == "string" and (.url | test("^https?://")))
|
||||
| [.label, .url, .note] | @tsv
|
||||
' "$link_file" 2>/dev/null) || exit 0
|
||||
[[ -n $pins ]] || exit 0
|
||||
|
||||
# bare urls rather than osc 8: claude code resolves plain http:// on ctrl+click
|
||||
# itself; COLUMNS is the only readable width since stdout is captured
|
||||
cols=${COLUMNS:-120}
|
||||
[[ $cols =~ ^[0-9]+$ ]] || cols=120
|
||||
gap=3
|
||||
row="" width=0
|
||||
while IFS=$'\t' read -r label url note; do
|
||||
len=$(( ${#label} + 1 + ${#url} + (${#note} > 0 ? ${#note} + 3 : 0) ))
|
||||
cost=$(( width ? len + gap : len ))
|
||||
if (( width && width + cost > cols - 4 )); then
|
||||
printf '%s\n' "$row"
|
||||
row="" width=$len
|
||||
else
|
||||
width=$(( width + cost ))
|
||||
fi
|
||||
cell="${C}${label}${R} ${D}${url}${R}"
|
||||
[[ -n $note ]] && cell+=" ${Y}${note}${R}"
|
||||
row+="${row:+${D} · ${R}}$cell"
|
||||
done <<<"$pins"
|
||||
[[ -n $row ]] && printf '%s\n' "$row"
|
||||
exit 0
|
||||
@@ -1,22 +0,0 @@
|
||||
[main]
|
||||
font=JetBrainsMono Nerd Font:size=10
|
||||
prompt=" "
|
||||
icons=no
|
||||
terminal=ghostty -e
|
||||
width=40
|
||||
lines=12
|
||||
layer=overlay
|
||||
exit-on-keyboard-focus-loss=yes
|
||||
|
||||
[colors]
|
||||
background=252423ff
|
||||
text=d4be98ff
|
||||
match=a9b665ff
|
||||
selection=45403dff
|
||||
selection-text=d4be98ff
|
||||
selection-match=a9b665ff
|
||||
border=45403dff
|
||||
|
||||
[border]
|
||||
width=2
|
||||
radius=8
|
||||
@@ -1,8 +1,11 @@
|
||||
theme = light:gruvbox-material-soft-light,dark:gruvbox-material-soft-dark
|
||||
# theme
|
||||
# theme = light:gruvbox-material-soft-light,dark:gruvbox-material-soft-dark
|
||||
theme = gruvbox-material-soft-dark
|
||||
|
||||
minimum-contrast = 1.1
|
||||
|
||||
# cursor
|
||||
cursor-style = block
|
||||
cursor-style-blink = true
|
||||
|
||||
# mouse
|
||||
@@ -22,10 +25,20 @@ window-theme = dark
|
||||
window-height = 36
|
||||
window-width = 150
|
||||
|
||||
# macos send option as alt
|
||||
macos-option-as-alt = true
|
||||
|
||||
# no warning full close
|
||||
confirm-close-surface = false
|
||||
# quit-after-last-window-closed = true
|
||||
|
||||
# keybinds
|
||||
keybind = global:cmd+enter=new_window
|
||||
|
||||
# shell integrations for zsh
|
||||
# https://ghostty.org/docs/features/shell-integration
|
||||
shell-integration = zsh
|
||||
|
||||
# gtk
|
||||
gtk-titlebar = false
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
palette = 0=#252423
|
||||
palette = 0=252423
|
||||
palette = 1=#ea6962
|
||||
palette = 2=#a9b665
|
||||
palette = 3=#d8a657
|
||||
|
||||
93
git/config
93
git/config
@@ -1,79 +1,56 @@
|
||||
[includeIf "gitdir:~/Desktop/git/git.aflabs.org/"]
|
||||
path = ~/.config/git/config.d/aflabs.conf
|
||||
|
||||
[includeIf "gitdir:~/Desktop/git/gitlab.com/flarenetwork"]
|
||||
path = ~/.config/git/config.d/aflabs.conf
|
||||
|
||||
[includeIf "gitdir:~/Desktop/git/gitlab.com/flare-foundation"]
|
||||
path = ~/.config/git/flare
|
||||
|
||||
[user]
|
||||
name = Matej Janežič
|
||||
email = janezic.mj@gmail.com
|
||||
signingkey = AF349EECC849D87B790E88FF6318FFB7DB374B7D
|
||||
|
||||
[commit]
|
||||
gpgsign = true
|
||||
verbose = true
|
||||
|
||||
; NOTE:(@janezicmatej) github/gitlab don't support these
|
||||
; [push]
|
||||
; gpgsign = true
|
||||
|
||||
[core]
|
||||
autocrlf = input
|
||||
editor = nvim
|
||||
|
||||
[user]
|
||||
signingkey = AF349EECC849D87B790E88FF6318FFB7DB374B7D
|
||||
|
||||
[commit]
|
||||
gpgsign = true
|
||||
|
||||
; NOTE:(@janezicmatej) github/gitlab don't support these
|
||||
; [push]
|
||||
; gpgsign = true
|
||||
|
||||
[init]
|
||||
defaultBranch = main
|
||||
|
||||
[rebase]
|
||||
updateRefs = true
|
||||
[alias]
|
||||
blame = "!f() { [ -f .git-blame-ignore-revs ] && git blame --ignore-revs-file .git-blame-ignore-revs \"$@\" || git blame \"$@\"; }; f"
|
||||
|
||||
[rerere]
|
||||
enabled = true
|
||||
autoUpdate = true
|
||||
[diff]
|
||||
tool = "nvimdiff"
|
||||
|
||||
[difftool]
|
||||
prompt = true
|
||||
|
||||
[difftool "nvimdiff"]
|
||||
cmd = nvim -d "$LOCAL" "$REMOTE"
|
||||
|
||||
[maintenance]
|
||||
auto = true
|
||||
strategy = incremental
|
||||
|
||||
[merge]
|
||||
tool = nvimdiff
|
||||
conflictStyle = zdiff3
|
||||
tool = "nvimdiff"
|
||||
|
||||
[mergetool]
|
||||
prompt = true
|
||||
keepBackup = false
|
||||
writeToTemp = true
|
||||
|
||||
[mergetool "nvimdiff"]
|
||||
layout = "LOCAL,BASE,REMOTE / MERGED"
|
||||
|
||||
[diff]
|
||||
tool = nvimdiff
|
||||
algorithm = histogram
|
||||
colorMoved = plain
|
||||
mnemonicPrefix = true
|
||||
|
||||
[difftool "nvimdiff"]
|
||||
cmd = nvim -d "$LOCAL" "$REMOTE"
|
||||
|
||||
[alias]
|
||||
blame-ignore = "config blame.ignoreRevsFile .git-blame-ignore-revs"
|
||||
# re-sign <upstream>..<branch> as the repo identity, keeping trees and merge topology
|
||||
sign-branch = "!~/.config/bin/git-sign-branch --reset-author"
|
||||
# list every commit in <range> without a good signature; exits 1 if any, git's status if the range is bad
|
||||
check-signed = "!f() { log=$(git log --format='%G? %h %s' \"${1:-main..HEAD}\") || exit; bad=$(printf '%s\\n' \"$log\" | grep -v -e '^[GU] ' -e '^$'); [ -z \"$bad\" ] && echo 'all signed' || { printf '%s\\n' \"$bad\"; exit 1; }; }; f"
|
||||
|
||||
[branch]
|
||||
sort = -committerdate
|
||||
|
||||
[tag]
|
||||
sort = version:refname
|
||||
|
||||
[help]
|
||||
autoCorrect = prompt
|
||||
|
||||
[maintenance]
|
||||
auto = true
|
||||
strategy = incremental
|
||||
|
||||
[includeIf "gitdir:~/git/git.aflabs.org/"]
|
||||
path = ~/.config/git/config.d/aflabs.conf
|
||||
|
||||
[includeIf "gitdir:~/git/gitlab.com/flarenetwork/"]
|
||||
path = ~/.config/git/config.d/flare.conf
|
||||
|
||||
[includeIf "gitdir:~/git/gitlab.com/flare-foundation/"]
|
||||
path = ~/.config/git/config.d/flare.conf
|
||||
|
||||
[hook "merge-msg"]
|
||||
command = ~/.config/git/hooks/merge-msg
|
||||
event = prepare-commit-msg
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
[user]
|
||||
email = matej.janezic@aflabs.com
|
||||
@@ -1,2 +0,0 @@
|
||||
[user]
|
||||
email = matej@flare.network
|
||||
@@ -1,22 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# rewrite default merge subject to `merge: <branch with dashes as spaces>`
|
||||
|
||||
msg_file="$1"
|
||||
source="$2"
|
||||
|
||||
[[ "$source" == "merge" ]] || exit 0
|
||||
|
||||
subject=$(head -n1 "$msg_file")
|
||||
|
||||
if [[ "$subject" =~ ^Merge\ remote-tracking\ branch\ \'[^/]+/([^\']+)\' ]]; then
|
||||
branch="${BASH_REMATCH[1]}"
|
||||
elif [[ "$subject" =~ ^Merge\ branch\ \'([^\']+)\' ]]; then
|
||||
branch="${BASH_REMATCH[1]}"
|
||||
else
|
||||
exit 0
|
||||
fi
|
||||
|
||||
{
|
||||
printf 'merge: %s\n' "${branch//-/ }"
|
||||
tail -n +2 "$msg_file"
|
||||
} > "$msg_file.tmp" && mv "$msg_file.tmp" "$msg_file"
|
||||
24
git/ignore
24
git/ignore
@@ -1,24 +1,20 @@
|
||||
# os
|
||||
# mac files
|
||||
.DS_Store
|
||||
|
||||
# editors
|
||||
# i don't like pushing .vscode and .idea folders, i will force add if needed
|
||||
.vscode
|
||||
.idea
|
||||
.ignore
|
||||
|
||||
# ai
|
||||
CLAUDE.local.md
|
||||
.claude
|
||||
.notes
|
||||
|
||||
# languages
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# nix
|
||||
# nix venv hook
|
||||
.nix-venv
|
||||
.envrc
|
||||
.direnv/
|
||||
.dev
|
||||
|
||||
# project-local
|
||||
# i use .ignore for nvim
|
||||
.ignore
|
||||
|
||||
# todo files
|
||||
TODO.md
|
||||
dumps
|
||||
|
||||
|
||||
28
mako/config
28
mako/config
@@ -1,28 +0,0 @@
|
||||
# gruvbox-material-soft-dark
|
||||
font=JetBrainsMono Nerd Font 11
|
||||
background-color=#252423
|
||||
text-color=#d4be98
|
||||
border-color=#45403d
|
||||
border-size=2
|
||||
border-radius=8
|
||||
padding=12
|
||||
|
||||
width=450
|
||||
height=300
|
||||
margin=8
|
||||
max-visible=3
|
||||
|
||||
default-timeout=10000
|
||||
ignore-timeout=0
|
||||
group-by=app-name
|
||||
max-icon-size=32
|
||||
icon-path=/usr/share/icons/Adwaita
|
||||
layer=overlay
|
||||
anchor=top-right
|
||||
|
||||
[urgency=low]
|
||||
default-timeout=5000
|
||||
|
||||
[urgency=critical]
|
||||
default-timeout=0
|
||||
border-color=#ea6962
|
||||
@@ -1,55 +1,44 @@
|
||||
add_newline = false
|
||||
|
||||
format = """\
|
||||
$username\
|
||||
$hostname\
|
||||
$directory\
|
||||
[$directory](cyan)\
|
||||
$git_branch\
|
||||
$git_commit\
|
||||
$git_state\
|
||||
$git_status\
|
||||
$character\
|
||||
"""
|
||||
|
||||
right_format = """\
|
||||
$nix_shell\
|
||||
$golang\
|
||||
$lua\
|
||||
$nodejs\
|
||||
$python\
|
||||
$rust\
|
||||
$dotnet\
|
||||
$c\
|
||||
$solidity\
|
||||
$terraform\
|
||||
$kubernetes\
|
||||
$helm\
|
||||
$cmd_duration\
|
||||
$line_break\
|
||||
$character\
|
||||
"""
|
||||
|
||||
[username]
|
||||
show_always = true
|
||||
format = '[$user](bold yellow)'
|
||||
|
||||
[hostname]
|
||||
ssh_only = false
|
||||
format = '[@$hostname](bold yellow) '
|
||||
|
||||
[directory]
|
||||
style = 'bold cyan'
|
||||
truncation_length = 1
|
||||
truncate_to_repo = false
|
||||
fish_style_pwd_dir_length = 1
|
||||
[nix_shell]
|
||||
disabled = false
|
||||
format = '[n(\($name\))](bold blue) '
|
||||
|
||||
[character]
|
||||
success_symbol = '[>](bold green)'
|
||||
error_symbol = '[>](bold red)'
|
||||
|
||||
[git_branch]
|
||||
format = 'on [$branch(:$remote_branch)]($style) '
|
||||
symbol = ''
|
||||
[directory]
|
||||
truncation_length = 1
|
||||
truncate_to_repo = false
|
||||
fish_style_pwd_dir_length = 1
|
||||
|
||||
[git_commit]
|
||||
tag_symbol = ''
|
||||
|
||||
[git_branch]
|
||||
symbol = ''
|
||||
|
||||
[git_status]
|
||||
format = '[$all_status$ahead_behind]($style)'
|
||||
ahead = '[↑${count}](green) '
|
||||
@@ -62,47 +51,13 @@ stashed = '[*${count}](green) '
|
||||
deleted = '[x${count}](red) '
|
||||
renamed = '[r${count}](yellow) '
|
||||
|
||||
[nix_shell]
|
||||
disabled = false
|
||||
format = '[nix](bold blue) '
|
||||
|
||||
[golang]
|
||||
symbol = 'go-'
|
||||
|
||||
[lua]
|
||||
symbol = 'lua-'
|
||||
|
||||
[nodejs]
|
||||
symbol = 'node-'
|
||||
|
||||
[python]
|
||||
symbol = 'py-'
|
||||
format = '[${symbol}(${version})]($style) '
|
||||
format = 'via [${symbol}${pyenv_prefix}(${version} )(\($virtualenv\) )]($style)'
|
||||
python_binary = 'python'
|
||||
pyenv_version_name = false
|
||||
detect_files = ['.python-version']
|
||||
detect_files = [ '.python-version' ]
|
||||
|
||||
[rust]
|
||||
symbol = 'rs-'
|
||||
|
||||
[c]
|
||||
symbol = 'c-'
|
||||
|
||||
[dotnet]
|
||||
symbol = 'net-'
|
||||
|
||||
[solidity]
|
||||
symbol = 'sol-'
|
||||
|
||||
[terraform]
|
||||
symbol = 'tf-'
|
||||
|
||||
[kubernetes]
|
||||
disabled = false
|
||||
symbol = 'k8s-'
|
||||
|
||||
[helm]
|
||||
symbol = 'helm-'
|
||||
|
||||
[cmd_duration]
|
||||
format = '[took $duration]($style) '
|
||||
[hostname]
|
||||
ssh_only = false
|
||||
style = 'bold yellow'
|
||||
format = '[$hostname]($style) on '
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
include host.d/"$(hostname)".conf
|
||||
include config.d/*
|
||||
include /etc/sway/config.d/*
|
||||
@@ -1,27 +0,0 @@
|
||||
set $mod Mod4
|
||||
|
||||
# vim style
|
||||
set $left h
|
||||
set $down j
|
||||
set $up k
|
||||
set $right l
|
||||
|
||||
# programs
|
||||
set $term ghostty
|
||||
set $menu fuzzel
|
||||
set $browser google-chrome-stable
|
||||
set $lockscreen swaylock
|
||||
|
||||
# other
|
||||
set $wobs /tmp/wob.sock
|
||||
|
||||
# style
|
||||
set $border 3
|
||||
|
||||
# colors
|
||||
set $bg0 #32302f
|
||||
set $bg1 #252423
|
||||
set $bg_overlay #45403d
|
||||
set $fg #d4be98
|
||||
set $red #ea6962
|
||||
set $gray #928374
|
||||
@@ -1 +0,0 @@
|
||||
output * bg ~/.assets/wallpaper.png fill
|
||||
@@ -1,5 +0,0 @@
|
||||
input "type:keyboard" {
|
||||
xkb_options "fkeys:basic_13-24"
|
||||
repeat_delay 150
|
||||
repeat_rate 35
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
font pango:JetBrainsMono Nerd Font Bold 10
|
||||
|
||||
default_floating_border normal $border
|
||||
default_border normal $border
|
||||
titlebar_border_thickness $border
|
||||
|
||||
# class border bground text indicator child_border
|
||||
client.focused $fg $bg0 $fg $fg $fg
|
||||
client.focused_inactive $bg1 $bg1 $gray $bg1 $bg1
|
||||
client.unfocused $bg1 $bg1 $gray $bg1 $bg1
|
||||
client.urgent $red $red $bg0 $red $red
|
||||
client.placeholder $bg1 $bg1 $fg $bg1 $bg1
|
||||
@@ -1,8 +0,0 @@
|
||||
titlebar_separator disable
|
||||
|
||||
shadows enable
|
||||
shadow_blur_radius 25
|
||||
shadow_color #0000007F
|
||||
shadow_offset 0 5
|
||||
|
||||
blur enable
|
||||
@@ -1,133 +0,0 @@
|
||||
# sway
|
||||
bindsym $mod+Shift+c reload
|
||||
bindsym $mod+Shift+e exec ~/.config/sway/scripts/power-menu.sh
|
||||
floating_modifier $mod normal
|
||||
|
||||
# programs
|
||||
bindsym $mod+Return exec $term
|
||||
bindsym $mod+d exec $menu
|
||||
bindsym $mod+u exec $lockscreen
|
||||
bindsym $mod+Shift+Ctrl+Alt+space exec 1password --quick-access
|
||||
bindsym Print exec ~/.config/sway/scripts/screenshot.sh
|
||||
|
||||
# notifications
|
||||
bindsym $mod+n exec makoctl dismiss
|
||||
bindsym $mod+Shift+n exec makoctl dismiss --all
|
||||
|
||||
# clipboard
|
||||
bindsym $mod+c exec cliphist list | fuzzel -d | cliphist decode | wl-copy
|
||||
|
||||
# output
|
||||
bindsym $mod+Shift+o exec ~/.config/sway/scripts/output-scale.sh toggle
|
||||
|
||||
# window
|
||||
bindsym $mod+Shift+q kill
|
||||
|
||||
# move focus
|
||||
bindsym $mod+$left focus left
|
||||
bindsym $mod+$down focus down
|
||||
bindsym $mod+$up focus up
|
||||
bindsym $mod+$right focus right
|
||||
|
||||
# move focused window
|
||||
bindsym $mod+Shift+$left move left
|
||||
bindsym $mod+Shift+$down move down
|
||||
bindsym $mod+Shift+$up move up
|
||||
bindsym $mod+Shift+$right move right
|
||||
|
||||
# switch to workspace
|
||||
bindsym $mod+1 workspace number 1
|
||||
bindsym $mod+2 workspace number 2
|
||||
bindsym $mod+3 workspace number 3
|
||||
bindsym $mod+4 workspace number 4
|
||||
bindsym $mod+5 workspace number 5
|
||||
bindsym $mod+6 workspace number 6
|
||||
bindsym $mod+7 workspace number 7
|
||||
bindsym $mod+8 workspace number 8
|
||||
bindsym $mod+9 workspace number 9
|
||||
bindsym $mod+0 workspace number 10
|
||||
|
||||
# move to workspace
|
||||
bindsym $mod+Shift+1 move container to workspace number 1
|
||||
bindsym $mod+Shift+2 move container to workspace number 2
|
||||
bindsym $mod+Shift+3 move container to workspace number 3
|
||||
bindsym $mod+Shift+4 move container to workspace number 4
|
||||
bindsym $mod+Shift+5 move container to workspace number 5
|
||||
bindsym $mod+Shift+6 move container to workspace number 6
|
||||
bindsym $mod+Shift+7 move container to workspace number 7
|
||||
bindsym $mod+Shift+8 move container to workspace number 8
|
||||
bindsym $mod+Shift+9 move container to workspace number 9
|
||||
bindsym $mod+Shift+0 move container to workspace number 10
|
||||
|
||||
bindsym $mod+Ctrl+1 exec ~/.config/sway/scripts/ws-to-current.sh 1
|
||||
bindsym $mod+Ctrl+2 exec ~/.config/sway/scripts/ws-to-current.sh 2
|
||||
bindsym $mod+Ctrl+3 exec ~/.config/sway/scripts/ws-to-current.sh 3
|
||||
bindsym $mod+Ctrl+4 exec ~/.config/sway/scripts/ws-to-current.sh 4
|
||||
bindsym $mod+Ctrl+5 exec ~/.config/sway/scripts/ws-to-current.sh 5
|
||||
bindsym $mod+Ctrl+6 exec ~/.config/sway/scripts/ws-to-current.sh 6
|
||||
bindsym $mod+Ctrl+7 exec ~/.config/sway/scripts/ws-to-current.sh 7
|
||||
bindsym $mod+Ctrl+8 exec ~/.config/sway/scripts/ws-to-current.sh 8
|
||||
bindsym $mod+Ctrl+9 exec ~/.config/sway/scripts/ws-to-current.sh 9
|
||||
bindsym $mod+Ctrl+0 exec ~/.config/sway/scripts/ws-to-current.sh 10
|
||||
|
||||
# layout
|
||||
bindsym $mod+b splith
|
||||
bindsym $mod+v splitv
|
||||
bindsym $mod+s layout stacking
|
||||
bindsym $mod+w layout tabbed
|
||||
bindsym $mod+e layout toggle split
|
||||
|
||||
# fullscreen
|
||||
bindsym $mod+f fullscreen
|
||||
|
||||
# focus
|
||||
bindsym $mod+Shift+space floating toggle; [floating] border pixel $border; [tiling] border normal $border
|
||||
bindsym $mod+space focus mode_toggle
|
||||
bindsym $mod+a focus parent
|
||||
|
||||
# scratchpad
|
||||
bindsym $mod+Shift+minus move scratchpad
|
||||
bindsym $mod+minus scratchpad show
|
||||
|
||||
# xf86-volume
|
||||
bindsym --locked XF86AudioMute exec pamixer -t && ( pamixer --get-mute && echo 0 > $wobs || pamixer --get-volume > $wobs )
|
||||
bindsym --locked XF86AudioLowerVolume exec pamixer -d 5 && pamixer --get-volume > $wobs
|
||||
bindsym --locked XF86AudioRaiseVolume exec pamixer -i 5 && pamixer --get-volume > $wobs
|
||||
bindsym --locked F16 exec pamixer --default-source -t
|
||||
|
||||
# xf86-brightness
|
||||
bindsym --locked XF86MonBrightnessDown exec brightnessctl set 5%-
|
||||
bindsym --locked XF86MonBrightnessUp exec brightnessctl set 5%+
|
||||
# xf86-audio
|
||||
bindsym --locked XF86AudioNext exec playerctl next
|
||||
bindsym --locked XF86AudioPause exec playerctl play-pause
|
||||
bindsym --locked XF86AudioPlay exec playerctl play-pause
|
||||
bindsym --locked XF86AudioPrev exec playerctl previous
|
||||
|
||||
# resize
|
||||
mode "resize" {
|
||||
# left will shrink the containers width
|
||||
# right will grow the containers width
|
||||
# up will shrink the containers height
|
||||
# down will grow the containers height
|
||||
bindsym $left resize shrink width 10px
|
||||
bindsym $down resize grow height 10px
|
||||
bindsym $up resize shrink height 10px
|
||||
bindsym $right resize grow width 10px
|
||||
|
||||
# Ditto, with arrow keys
|
||||
bindsym Left resize shrink width 10px
|
||||
bindsym Down resize grow height 10px
|
||||
bindsym Up resize shrink height 10px
|
||||
bindsym Right resize grow width 10px
|
||||
|
||||
# Return to default mode
|
||||
bindsym Return mode "default"
|
||||
bindsym Escape mode "default"
|
||||
}
|
||||
bindsym $mod+r mode "resize"
|
||||
|
||||
mode "passthrough" {
|
||||
bindsym $mod+Shift+Escape mode "default"
|
||||
}
|
||||
bindsym $mod+Shift+Escape mode "passthrough"
|
||||
@@ -1,26 +0,0 @@
|
||||
# additional theming
|
||||
for_window [floating] border pixel $border
|
||||
for_window [tiling] border normal $border
|
||||
|
||||
for_window [app_id="com.mitchellh.ghostty" title="fastfetch"] floating enable, focus, move position 50 50
|
||||
|
||||
# workspace assignment
|
||||
# NOTE:(@janezicmatej) app_ids change casing and separators across packaging; matches are case-insensitive substrings
|
||||
assign [app_id="(?i)google-chrome"] workspace 2
|
||||
assign [app_id="(?i)thunderbird"] workspace 3
|
||||
assign [app_id="(?i)cider"] workspace 4
|
||||
assign [app_id="(?i)boltlauncher"] workspace 5
|
||||
assign [class="(?i)net-runelite"] workspace 5
|
||||
assign [app_id="(?i)discord"] workspace 8
|
||||
assign [app_id="(?i)slack"] workspace 8
|
||||
assign [app_id="(?i)telegram"] workspace 8
|
||||
assign [app_id="(?i)rocket.?chat"] workspace 8
|
||||
|
||||
# rules
|
||||
for_window [app_id="com.gabm.satty"] fullscreen enable
|
||||
for_window [class="net-runelite.*"] floating enable
|
||||
no_focus [class="net-runelite.*"]
|
||||
|
||||
# idle
|
||||
for_window [app_id=".*"] inhibit_idle fullscreen
|
||||
for_window [class=".*"] inhibit_idle fullscreen
|
||||
@@ -1,3 +0,0 @@
|
||||
bar {
|
||||
swaybar_command waybar
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
# daemon
|
||||
exec mako
|
||||
exec nm-applet --indicator
|
||||
exec swayidle -w
|
||||
exec wlsunset -l 46.1 -L 14.5
|
||||
exec pkill -x wob; rm -f $wobs && mkfifo $wobs && tail -f $wobs | wob
|
||||
exec wl-paste --watch cliphist store
|
||||
exec ~/.config/sway/scripts/power-aware-effects.sh
|
||||
exec protonmail-bridge -n
|
||||
|
||||
# traditional "start when os starts" programs
|
||||
exec ghostty --title=fastfetch --window-width=105 --window-height=27 -e bash -c 'nix run nixpkgs#fastfetch; read -rsn1' && ghostty
|
||||
exec $browser
|
||||
exec thunderbird
|
||||
exec cider-2
|
||||
exec bolt-launcher
|
||||
exec discord
|
||||
exec slack
|
||||
exec Telegram
|
||||
exec rocketchat-desktop
|
||||
exec 1password --silent
|
||||
@@ -1,4 +0,0 @@
|
||||
# bottom-aligned and adjacent to eDP-1 at scale 1
|
||||
# NOTE:(@janezicmatej) scripts/output-scale.sh recomputes both positions on scale change
|
||||
output DP-7 resolution 2560x1440 position 2560 160
|
||||
output eDP-1 resolution 2560x1600@165Hz position 0 0 scale 1 adaptive_sync on
|
||||
@@ -1,2 +0,0 @@
|
||||
output DP-2 resolution 2560x1440@179.952Hz position 0 0
|
||||
output HDMI-A-2 resolution 2560x1440@74.971Hz position 2560 0
|
||||
@@ -1,16 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# toggle swayfx blur and shadows based on battery state
|
||||
|
||||
while true; do
|
||||
status=$(cat /sys/class/power_supply/BAT1/status 2>/dev/null || cat /sys/class/power_supply/BAT0/status)
|
||||
|
||||
if [ "$status" = "Discharging" ]; then
|
||||
swaymsg blur off
|
||||
swaymsg shadows off
|
||||
else
|
||||
swaymsg blur on
|
||||
swaymsg shadows on
|
||||
fi
|
||||
|
||||
sleep 30
|
||||
done
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# power menu via fuzzel
|
||||
|
||||
choice=$(printf "lock\nlogout\nsuspend\nreboot\nshutdown" | fuzzel -d -p "power: ")
|
||||
|
||||
case "$choice" in
|
||||
lock) swaylock ;;
|
||||
logout) swaymsg exit ;;
|
||||
suspend) systemctl suspend ;;
|
||||
reboot) systemctl reboot ;;
|
||||
shutdown) systemctl poweroff ;;
|
||||
esac
|
||||
@@ -1,6 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# screenshot focused monitor with satty
|
||||
output=$(swaymsg -t get_outputs | jq -r '.[] | select(.focused) | .name')
|
||||
grim -o "$output" - | satty -f - --fullscreen --early-exit --copy-command wl-copy
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
# switch to workspace and move it to the currently focused output
|
||||
output=$(swaymsg -t get_outputs | jq -r '.[] | select(.focused) | .name')
|
||||
swaymsg "workspace number $1, move workspace to output $output"
|
||||
@@ -1,4 +0,0 @@
|
||||
lock 'swaylock -f'
|
||||
timeout 300 'swaylock -f'
|
||||
timeout 600 'swaymsg "output * power off"' resume 'swaymsg "output * power on"'
|
||||
before-sleep 'swaylock -f'
|
||||
@@ -1,35 +0,0 @@
|
||||
ignore-empty-password
|
||||
show-failed-attempts
|
||||
|
||||
image=~/.assets/wallpaper.png
|
||||
|
||||
clock
|
||||
datestr=%A, %d/%m/%y
|
||||
|
||||
indicator-radius=150
|
||||
indicator-thickness=7
|
||||
indicator-idle-visible
|
||||
indicator-y-position=300
|
||||
indicator-x-position=1200
|
||||
|
||||
# gruvbox-material-soft-dark
|
||||
inside-color=#32302f
|
||||
text-color=#d4be98
|
||||
ring-color=#928374
|
||||
|
||||
inside-ver-color=#32302f
|
||||
text-ver-color=#a9b665
|
||||
ring-ver-color=#a9b665
|
||||
|
||||
inside-clear-color=#32302f
|
||||
text-clear-color=#d8a657
|
||||
ring-clear-color=#d8a657
|
||||
|
||||
inside-wrong-color=#32302f
|
||||
text-wrong-color=#ea6962
|
||||
ring-wrong-color=#ea6962
|
||||
|
||||
key-hl-color=#7daea3
|
||||
bs-hl-color=#ea6962
|
||||
caps-lock-key-hl-color=#d8a657
|
||||
caps-lock-bs-hl-color=#ea6962
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ $# -eq 1 ]]; then
|
||||
selected=$1
|
||||
else
|
||||
selected=$(ggman ls \
|
||||
| fzf \
|
||||
--border-label "tmux-sessionizer" \
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ -z $selected ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
selected_name="$(echo "$selected" | shasum -a 256 | head -c 4)_$(basename "$selected" | tr . _ | head -c 10)"
|
||||
|
||||
if [[ -z $TMUX ]] && ! tmux list-sessions 2>/dev/null; then
|
||||
tmux new-session -s "$selected_name" -c "$selected"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! tmux has-session -t="$selected_name" 2>/dev/null; then
|
||||
tmux new-session -ds "$selected_name" -c "$selected"
|
||||
fi
|
||||
|
||||
tmux switch-client -t "$selected_name"
|
||||
@@ -1,47 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# open (or switch to) a tmux session running ssh to a host
|
||||
# tmux-ssher [host [luks]]
|
||||
|
||||
if [[ $# -ge 1 ]]; then
|
||||
host=$1
|
||||
profile=${2:-}
|
||||
else
|
||||
IFS=$'\t' read -r host profile < <(~/.config/bin/ssh-menu -s)
|
||||
fi
|
||||
|
||||
if [[ -z $host ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# session names cannot contain . or :
|
||||
name=${host//[.:]/_}
|
||||
|
||||
# an unlock session is short-lived and must not collide with a live ssh session
|
||||
if [[ $profile == luks ]]; then
|
||||
session="unlock_$name"
|
||||
args=(-P luks "$host")
|
||||
else
|
||||
session="ssh_$name"
|
||||
args=("$host")
|
||||
fi
|
||||
|
||||
# on failure keep the window until enter, so the error stays readable (e.g.
|
||||
# publickey denied because the yubikey is not inserted); tmux.conf's
|
||||
# detach-on-destroy then returns to the previous session. bash, not the tmux
|
||||
# default shell, so read -p behaves
|
||||
cmd="bash -c 'ssh \"\$@\" || { rc=\$?; echo; read -rp \"ssh exited \$rc, press enter to close \"; }' _$(printf ' %q' "${args[@]}")"
|
||||
|
||||
if ! tmux has-session -t="$session" 2>/dev/null; then
|
||||
tmux new-session -ds "$session" -n "$host" "$cmd"
|
||||
# tmux.conf's c / " / % bindings read this to open more panes on the same host
|
||||
if [[ $profile != luks ]]; then
|
||||
tmux set-option -t "=$session:" @ssh_host "$host"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z $TMUX ]]; then
|
||||
tmux attach-session -t "=$session"
|
||||
else
|
||||
tmux switch-client -t "=$session"
|
||||
fi
|
||||
@@ -1,5 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
source "$XDG_CONFIG_HOME/zsh/scripts.zsh"
|
||||
|
||||
if [[ $# -eq 1 ]]; then
|
||||
selected=$1
|
||||
else
|
||||
@@ -10,7 +12,7 @@ if [[ -z $selected ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ggman clone "$selected"
|
||||
ggman clone $selected
|
||||
|
||||
echo ""
|
||||
read -p "press enter to continue..."
|
||||
@@ -6,6 +6,8 @@ if [[ -z ${NAME:+x} ]]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source "$XDG_CONFIG_HOME/zsh/scripts.zsh"
|
||||
|
||||
read -p "name: " project_name
|
||||
|
||||
if [[ -z $project_name ]]; then
|
||||
@@ -17,16 +19,16 @@ if [[ -d "$GGROOT/no-remote/$project_name" ]]; then
|
||||
fi
|
||||
|
||||
project="$GGROOT/no-remote/$project_name"
|
||||
git init "$project"
|
||||
git init $project
|
||||
echo "# $project_name" >>"$project/README.md"
|
||||
|
||||
gitignore_template=$(
|
||||
find "$GGROOT"/github.com/github/gitignore/*.gitignore -exec basename {} \; \
|
||||
| sed 's/\(.*\)\.gitignore/\1/' \
|
||||
| fzf
|
||||
| fzf --cycle --bind 'tab:toggle-up,btab:toggle-down'
|
||||
)
|
||||
|
||||
if [[ -n $gitignore_template ]]; then
|
||||
if ! [[ -z $gitignore_template ]]; then
|
||||
gitignore="$GGROOT/github.com/github/gitignore/$gitignore_template.gitignore"
|
||||
echo "$gitignore"
|
||||
echo "$project/.gitignore"
|
||||
@@ -36,11 +38,13 @@ fi
|
||||
license_template=$(
|
||||
find "$GGROOT"/git.janezic.dev/janezicmatej/license/*.license -exec basename {} \; \
|
||||
| sed 's/\(.*\)\.license/\1/' \
|
||||
| fzf
|
||||
| fzf --cycle --bind 'tab:toggle-up,btab:toggle-down'
|
||||
)
|
||||
|
||||
if [[ -n $license_template ]]; then
|
||||
if ! [[ -z $license_template ]]; then
|
||||
license="$GGROOT/git.janezic.dev/janezicmatej/license/$license_template.license"
|
||||
export YEAR="$(date '+%Y')"
|
||||
envsubst <"$license" >"$project/LICENSE"
|
||||
fi
|
||||
|
||||
ggcompile
|
||||
34
tmux/tmux-sessionizer
Executable file
34
tmux/tmux-sessionizer
Executable file
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
if [[ $# -eq 1 ]]; then
|
||||
selected=$1
|
||||
else
|
||||
selected=$(ggman ls \
|
||||
| fzf --cycle \
|
||||
--bind 'tab:toggle-up,btab:toggle-down' \
|
||||
--header "Navigate with ARROW KEYS or TAB/S-TAB. Select with ENTER." \
|
||||
--border "double" \
|
||||
--border-label "tmux-sessionizer" \
|
||||
--ansi \
|
||||
--highlight-line \
|
||||
)
|
||||
fi
|
||||
|
||||
if [[ -z $selected ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
selected_name="$(echo $selected | shasum -a 256 | head -c 4)_$(basename $selected | tr . _ | head -c 10)"
|
||||
|
||||
tmux_running=$(pgrep tmux)
|
||||
|
||||
if [[ -z $TMUX ]] && [[ -z $tmux_running ]]; then
|
||||
tmux new-session -s $selected_name -c $selected
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! tmux has-session -t=$selected_name 2> /dev/null; then
|
||||
tmux new-session -ds $selected_name -c $selected
|
||||
fi
|
||||
|
||||
tmux switch-client -t $selected_name
|
||||
77
tmux/tmux-ssher
Executable file
77
tmux/tmux-ssher
Executable file
@@ -0,0 +1,77 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function _preview {
|
||||
file=$1; shift
|
||||
host=$1; shift
|
||||
|
||||
j=$(cat $file \
|
||||
| jc --ssh-conf \
|
||||
| jq -r ".[] \
|
||||
| select(.host == \"$host\") \
|
||||
| .jumps = (if (.proxyjump | type == \"array\" and length > 0) then (.proxyjump | join(\",\")) else \"null\" end)" \
|
||||
)
|
||||
|
||||
user=$(jq -r '.user' <<<$j)
|
||||
port=$(jq -r '.port' <<<$j)
|
||||
hostname=$(jq -r '.hostname' <<<$j)
|
||||
jumps=$(jq -r '.jumps' <<<$j)
|
||||
|
||||
|
||||
echo "$(cat <<EOF
|
||||
User $user
|
||||
HostName $hostname\
|
||||
$([[ $port != "null" ]] && echo -e "\nPort $port")\
|
||||
$([[ $jumps != "null" ]] && echo -e "\nProxyJump $jumps")
|
||||
|
||||
command:
|
||||
ssh $user@$hostname$([[ $port != "null" ]] && echo -n " -p $port")$([[ $jumps != "null" ]] && echo -n " -J $jumps")
|
||||
EOF)"
|
||||
|
||||
echo ""
|
||||
|
||||
host $hostname
|
||||
|
||||
echo ""
|
||||
|
||||
ping -c 1 -W 1 $hostname &>/dev/null \
|
||||
&& echo "Host is reachable!" \
|
||||
|| echo "Host is not reachable!"
|
||||
}
|
||||
|
||||
export -f _preview
|
||||
|
||||
function main() {
|
||||
if [[ $# -eq 1 ]]; then
|
||||
selected=$1
|
||||
else
|
||||
selected="$(rg -N --no-heading '^Host .*' ~/.ssh \
|
||||
| sed 's/Host \(.*\)/\1/' \
|
||||
| SHELL=$(which bash) fzf --cycle \
|
||||
--bind 'tab:toggle-up,btab:toggle-down' \
|
||||
--delimiter ':' \
|
||||
--with-nth 2 \
|
||||
--preview "_preview {1} {2}" \
|
||||
| cut -d ":" -f2
|
||||
)"
|
||||
fi
|
||||
|
||||
if [[ -z $selected ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
selected_name="ssh_$selected"
|
||||
tmux_running=$(pgrep tmux)
|
||||
|
||||
if [[ -z $TMUX ]] && [[ -z $tmux_running ]]; then
|
||||
tmux new-session -s $selected_name -c $selected ssh "$selected"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if ! tmux has-session -t=$selected_name 2> /dev/null; then
|
||||
tmux new-session -ds $selected_name -c $selected ssh "$selected"
|
||||
fi
|
||||
|
||||
tmux switch-client -t $selected_name
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -1,67 +1,53 @@
|
||||
# server options
|
||||
set -g default-terminal "xterm-256color"
|
||||
set -ga terminal-overrides ",xterm-256color:RGB"
|
||||
set -ga terminal-features ",xterm-256color:clipboard"
|
||||
|
||||
set -g allow-passthrough on
|
||||
set -g set-clipboard on
|
||||
|
||||
set -s escape-time 0
|
||||
|
||||
# terminal
|
||||
set -g default-terminal "tmux-256color"
|
||||
set -ga terminal-overrides ",xterm-ghostty:RGB,xterm-256color:RGB"
|
||||
set -ga terminal-features ",xterm-ghostty:clipboard,xterm-256color:clipboard"
|
||||
set -g set-clipboard on
|
||||
set -g allow-passthrough on
|
||||
|
||||
# general
|
||||
set -g prefix C-s
|
||||
set -g base-index 1
|
||||
set -g focus-events on
|
||||
set -g update-environment "SSH_AUTH_SOCK SSH_CONNECTION SSH_TTY DISPLAY"
|
||||
# when a session dies under the client (e.g. a failed ssh in tmux-ssher) go to
|
||||
# the most recently used session instead of detaching. not "previous": that
|
||||
# path crashes the whole server on 3.7b+ (tmux issue 5344, fixed after 3.7c)
|
||||
set -g detach-on-destroy off
|
||||
|
||||
# status bar
|
||||
unbind C-b
|
||||
set-option -g prefix C-s
|
||||
|
||||
bind r source-file "$XDG_CONFIG_HOME/tmux/tmux.conf"
|
||||
set -g base-index 1
|
||||
|
||||
set -g status-style 'bg=default fg=default'
|
||||
set -g status-left-length 20
|
||||
|
||||
# window options
|
||||
set -wg mode-keys vi
|
||||
set -g pane-base-index 1
|
||||
set -g renumber-windows on
|
||||
|
||||
# prefix
|
||||
unbind C-b
|
||||
|
||||
# copy mode
|
||||
set-window-option -g mode-keys vi
|
||||
bind -T copy-mode-vi v send-keys -X begin-selection
|
||||
bind -T copy-mode-vi y send-keys -X copy-selection-and-cancel
|
||||
|
||||
# navigation
|
||||
# differentiate copy on linux and max
|
||||
if-shell -b '[ "$(uname -s)" = "Linux" ]' {
|
||||
bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel 'xclip -in -selection clipboard'
|
||||
}
|
||||
|
||||
if-shell -b '[ "$(uname -s)" = "Darwin" ]' {
|
||||
bind -T copy-mode-vi y send-keys -X copy-pipe-and-cancel 'pbcopy'
|
||||
}
|
||||
|
||||
# vim-like pane switching
|
||||
bind ^ last-window
|
||||
bind k select-pane -U
|
||||
bind j select-pane -D
|
||||
bind h select-pane -L
|
||||
bind l select-pane -R
|
||||
|
||||
# windows and panes: inside an ssh session (@ssh_host set by tmux-ssher) new
|
||||
# panes open on the same host over the multiplexed connection.
|
||||
# new-window/split-window do not expand formats in their command, run-shell does
|
||||
bind c if-shell -F "#{@ssh_host}" 'run-shell "tmux new-window -t \"#{session_name}\" -n \"#{@ssh_host}\" \"ssh #{@ssh_host}\""' "new-window"
|
||||
bind '"' if-shell -F "#{@ssh_host}" 'run-shell "tmux split-window -v -t \"#{pane_id}\" \"ssh #{@ssh_host}\""' "split-window -v"
|
||||
bind % if-shell -F "#{@ssh_host}" 'run-shell "tmux split-window -h -t \"#{pane_id}\" \"ssh #{@ssh_host}\""' "split-window -h"
|
||||
|
||||
# reload
|
||||
bind r source-file "$XDG_CONFIG_HOME/tmux/tmux.conf"
|
||||
|
||||
# sessionizer
|
||||
bind f run-shell "tmux neww ~/.config/tmux/scripts/tmux-sessionizer"
|
||||
bind R run-shell "tmux neww ~/.config/tmux/scripts/tmux-sessionizer ~"
|
||||
bind g run-shell "tmux neww ~/.config/tmux/scripts/tmux-sessionizer ~/.config"
|
||||
bind T run-shell "tmux neww ~/.config/tmux/scripts/tmux-sessionizer ~/temp"
|
||||
bind-key f run-shell "tmux neww ~/.config/tmux/tmux-sessionizer"
|
||||
bind-key R run-shell "tmux neww ~/.config/tmux/tmux-sessionizer ~"
|
||||
bind-key g run-shell "tmux neww ~/.config/tmux/tmux-sessionizer ~/.config"
|
||||
bind-key T run-shell "tmux neww ~/.config/tmux/tmux-sessionizer ~/temp"
|
||||
|
||||
# clone
|
||||
bind G run-shell "tmux neww ~/.config/tmux/scripts/tmux-ggclone"
|
||||
bind-key G run-shell "tmux neww ~/.config/tmux/tmux-ggclone"
|
||||
|
||||
# ssh
|
||||
bind F run-shell "tmux neww ~/.config/tmux/scripts/tmux-ssher"
|
||||
bind-key F run-shell "tmux neww ~/.config/tmux/tmux-ssher"
|
||||
|
||||
# project
|
||||
bind N run-shell "tmux neww ~/.config/tmux/scripts/tmux-new-project"
|
||||
bind-key N run-shell "tmux neww ~/.config/tmux/tmux-new-project"
|
||||
|
||||
@@ -1,142 +0,0 @@
|
||||
[
|
||||
{
|
||||
"layer": "top",
|
||||
"position": "top",
|
||||
"height": 30,
|
||||
"spacing": 1,
|
||||
"margin": 0,
|
||||
"modules-left": [
|
||||
"sway/workspaces",
|
||||
"sway/mode"
|
||||
],
|
||||
"modules-center": [
|
||||
"custom/ssh",
|
||||
"systemd-failed-units"
|
||||
],
|
||||
"modules-right": [
|
||||
"mpris",
|
||||
"cpu",
|
||||
"memory",
|
||||
"battery",
|
||||
"wireplumber",
|
||||
"custom/mic",
|
||||
"backlight",
|
||||
"sway/language",
|
||||
"bluetooth",
|
||||
"custom/network",
|
||||
"clock"
|
||||
],
|
||||
|
||||
"sway/workspaces": {
|
||||
"disable-scroll": true,
|
||||
"format": "{name}"
|
||||
},
|
||||
|
||||
"sway/mode": {
|
||||
"format": "{}"
|
||||
},
|
||||
|
||||
"sway/language": {
|
||||
"format": "{short}",
|
||||
"on-click": "swaymsg input type:keyboard xkb_switch_layout next"
|
||||
},
|
||||
|
||||
"custom/ssh": {
|
||||
"format": "{}",
|
||||
"return-type": "json",
|
||||
"interval": 15,
|
||||
"exec": "~/.config/waybar/scripts/ssh-session.sh",
|
||||
"on-click": "~/.config/waybar/scripts/ssh-session.sh disconnect"
|
||||
},
|
||||
|
||||
"systemd-failed-units": {
|
||||
"hide-on-ok": true,
|
||||
"format": "!{nr_failed}",
|
||||
"format-ok": "",
|
||||
"system": true,
|
||||
"user": true,
|
||||
"on-click": "ghostty -e sh -c 'systemctl --failed; systemctl --user --failed; read'"
|
||||
},
|
||||
|
||||
"mpris": {
|
||||
"format": "{artist} - {title}",
|
||||
"format-paused": "{artist} - {title} [paused]",
|
||||
"format-stopped": "",
|
||||
"max-length": 35,
|
||||
"tooltip-format": "{player}: {artist} - {title} ({album})",
|
||||
"on-click": "playerctl play-pause",
|
||||
"on-scroll-up": "playerctl next",
|
||||
"on-scroll-down": "playerctl previous"
|
||||
},
|
||||
|
||||
"custom/network": {
|
||||
"format": "{}",
|
||||
"return-type": "json",
|
||||
"interval": 5,
|
||||
"exec": "~/.config/waybar/scripts/network-status.sh"
|
||||
},
|
||||
|
||||
"bluetooth": {
|
||||
"format": "",
|
||||
"format-connected": " {num_connections}",
|
||||
"format-connected-battery": " {num_connections}",
|
||||
"tooltip-format-connected": "{device_enumerate}"
|
||||
},
|
||||
|
||||
"cpu": {
|
||||
"format": " {usage}%",
|
||||
"tooltip": false
|
||||
},
|
||||
|
||||
"memory": {
|
||||
"interval": 10,
|
||||
"format": " {percentage}%",
|
||||
"tooltip-format": "total: {total:0.2f}GiB\nused: {used:0.2f}GiB\navailable: {avail:0.2f}GiB\nswap: {swapUsed:0.2f}/{swapTotal:0.2f}GiB"
|
||||
},
|
||||
|
||||
"battery": {
|
||||
"states": {
|
||||
"warning": 30,
|
||||
"critical": 15
|
||||
},
|
||||
"format": " {capacity}%",
|
||||
"format-charging": " {capacity}%",
|
||||
"format-plugged": " {capacity}%",
|
||||
"format-full": " full",
|
||||
"format-alt": "{time}",
|
||||
"tooltip-format": "{timeTo}\n{power}W",
|
||||
"on-click": "p=$(powerprofilesctl get); case $p in power-saver) n=balanced;; balanced) n=performance;; *) n=power-saver;; esac; powerprofilesctl set $n"
|
||||
},
|
||||
|
||||
"wireplumber": {
|
||||
"format": " {volume}%",
|
||||
"format-muted": " muted",
|
||||
"on-click": "pamixer -t",
|
||||
"on-scroll-up": "pamixer -i 5",
|
||||
"on-scroll-down": "pamixer -d 5",
|
||||
"tooltip-format": "{node_name}: {volume}%"
|
||||
},
|
||||
|
||||
"custom/mic": {
|
||||
"format": "{}",
|
||||
"return-type": "json",
|
||||
"interval": 2,
|
||||
"exec": "~/.config/waybar/scripts/mic-status.sh",
|
||||
"on-click": "pamixer --default-source -t",
|
||||
"on-scroll-up": "pamixer --default-source -i 5",
|
||||
"on-scroll-down": "pamixer --default-source -d 5"
|
||||
},
|
||||
|
||||
"backlight": {
|
||||
"format": " {percent}%",
|
||||
"tooltip": false
|
||||
},
|
||||
|
||||
"clock": {
|
||||
"interval": 1,
|
||||
"format": "{:%d/%m %H:%M:%S}",
|
||||
"tooltip-format": "<big><tt>{calendar}</tt></big>"
|
||||
}
|
||||
|
||||
}
|
||||
]
|
||||
@@ -1,12 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# mic volume for waybar custom module
|
||||
|
||||
vol=$(pamixer --default-source --get-volume 2>/dev/null || echo 0)
|
||||
muted=$(pamixer --default-source --get-mute 2>/dev/null || echo false)
|
||||
|
||||
if [[ "$muted" == "true" ]]; then
|
||||
printf '{"text": "", "class": "muted", "tooltip": "mic muted"}\n'
|
||||
else
|
||||
printf '{"text": " %d%%", "class": "", "tooltip": "mic %d%%"}\n' "$vol" "$vol"
|
||||
fi
|
||||
@@ -1,73 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# combined network + vpn status for waybar
|
||||
# format: icon ip/cidr [vpn-name ...]
|
||||
|
||||
iface=$(ip route show default 2>/dev/null | awk '/default/ {print $5; exit}')
|
||||
icon=""
|
||||
class="disconnected"
|
||||
net_text=""
|
||||
vpn_names=()
|
||||
tooltip_parts=()
|
||||
|
||||
# network
|
||||
if [[ -n "$iface" ]]; then
|
||||
cidr=$(ip -4 addr show "$iface" 2>/dev/null | awk '/inet / {print $2; exit}')
|
||||
gw=$(ip route show default 2>/dev/null | awk '/default/ {print $3; exit}')
|
||||
|
||||
if [[ "$iface" == wl* ]]; then
|
||||
icon=""
|
||||
class="wifi"
|
||||
ssid=$(nmcli -t -f active,ssid dev wifi 2>/dev/null | awk -F: '/^yes:/ {print $2}')
|
||||
signal=$(nmcli -t -f active,signal dev wifi 2>/dev/null | awk -F: '/^yes:/ {print $2}')
|
||||
tooltip_parts+=("$iface: $cidr via $gw")
|
||||
[[ -n "$ssid" ]] && tooltip_parts+=("ssid: $ssid (${signal}%)")
|
||||
else
|
||||
icon=""
|
||||
class="wired"
|
||||
tooltip_parts+=("$iface: $cidr via $gw")
|
||||
fi
|
||||
net_text="$cidr"
|
||||
else
|
||||
tooltip_parts+=("no connection")
|
||||
fi
|
||||
|
||||
# wireguard — show config name (interface name, e.g. wg-office)
|
||||
if command -v wg &>/dev/null; then
|
||||
for wg_iface in $(wg show interfaces 2>/dev/null); do
|
||||
wg_cidr=$(ip -4 addr show "$wg_iface" 2>/dev/null | awk '/inet / {print $2; exit}')
|
||||
vpn_names+=("$wg_iface")
|
||||
tooltip_parts+=("wg: $wg_iface $wg_cidr")
|
||||
done
|
||||
fi
|
||||
|
||||
# tailscale — show exit node name if routed, otherwise tailnet name
|
||||
if command -v tailscale &>/dev/null; then
|
||||
ts_json=$(tailscale status --json 2>/dev/null)
|
||||
ts_state=$(jq -r '.BackendState // empty' <<<"$ts_json")
|
||||
if [[ "$ts_state" == "Running" ]]; then
|
||||
ts_ip=$(jq -r '.TailscaleIPs[0] // empty' <<<"$ts_json")
|
||||
ts_self=$(jq -r '.Self.HostName // empty' <<<"$ts_json")
|
||||
ts_exit_id=$(jq -r '.ExitNodeStatus.ID // empty' <<<"$ts_json")
|
||||
|
||||
if [[ -n "$ts_exit_id" ]]; then
|
||||
exit_name=$(jq -r --arg id "$ts_exit_id" '[.Peer[] | select(.ID == $id) | .HostName][0] // "exit"' <<<"$ts_json")
|
||||
vpn_names+=("$exit_name")
|
||||
tooltip_parts+=("ts: $ts_self $ts_ip → $exit_name")
|
||||
else
|
||||
tooltip_parts+=("ts: $ts_self $ts_ip")
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# build text
|
||||
text="$icon"
|
||||
[[ -n "$net_text" ]] && text+=" $net_text"
|
||||
if [[ ${#vpn_names[@]} -gt 0 ]]; then
|
||||
text+=" [$(IFS=,; echo "${vpn_names[*]}")]"
|
||||
fi
|
||||
|
||||
tooltip=$(printf '%s\n' "${tooltip_parts[@]}")
|
||||
|
||||
jq -nc --arg text "$text" --arg class "$class" --arg tooltip "$tooltip" \
|
||||
'{$text,$class,$tooltip}'
|
||||
@@ -1,21 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# detect remote ssh sessions and optionally disconnect them
|
||||
# outputs waybar JSON; empty when no remote sessions
|
||||
|
||||
if [[ "$1" == "disconnect" ]]; then
|
||||
pkill -HUP -f 'sshd-session:.*@' 2>/dev/null
|
||||
exit 0
|
||||
fi
|
||||
|
||||
count=$(pgrep -cf '[s]shd-session:.*@' 2>/dev/null)
|
||||
count=${count:-0}
|
||||
|
||||
if [[ "$count" -gt 0 ]]; then
|
||||
sessions=$(who 2>/dev/null | awk '$NF ~ /\([0-9]/ {gsub(/[()]/, "", $NF); print $1 "@" $NF}')
|
||||
label="session"; [[ "$count" -gt 1 ]] && label="sessions"
|
||||
tooltip=${sessions:-"$count remote $label"}
|
||||
tooltip=${tooltip//$'\n'/\\n}
|
||||
tooltip=${tooltip//\"/\\\"}
|
||||
printf '{"text": "●", "class": "active", "tooltip": "%s"}\n' "$tooltip"
|
||||
fi
|
||||
231
waybar/style.css
231
waybar/style.css
@@ -1,231 +0,0 @@
|
||||
/* gruvbox-material-soft-dark palette
|
||||
* mapped from ghostty/themes/gruvbox-material-soft-dark
|
||||
* ansi 0=#252423 1=#ea6962 2=#a9b665 3=#d8a657
|
||||
* 4=#7daea3 5=#d3869b 6=#89b482 7=#d4be98
|
||||
* bg=#32302f fg=#d4be98 selection=#45403d */
|
||||
@define-color bg #32302f;
|
||||
@define-color bg_dim #252423;
|
||||
@define-color bg_sel #45403d;
|
||||
@define-color fg #d4be98;
|
||||
@define-color red #ea6962;
|
||||
@define-color green #a9b665;
|
||||
@define-color yellow #d8a657;
|
||||
@define-color blue #7daea3;
|
||||
@define-color magenta #d3869b;
|
||||
@define-color cyan #89b482;
|
||||
@define-color gray #928374;
|
||||
|
||||
* {
|
||||
font-family: "JetBrainsMono Nerd Font";
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* bar: dark background, modules float inside */
|
||||
window#waybar {
|
||||
background-color: @bg_dim;
|
||||
color: @fg;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
window#waybar.hidden {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
button {
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: inherit;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* workspaces */
|
||||
#workspaces {
|
||||
background-color: @bg;
|
||||
border-radius: 8px;
|
||||
margin: 3px 2px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
#workspaces button {
|
||||
padding: 0 8px;
|
||||
background-color: transparent;
|
||||
color: @gray;
|
||||
margin: 0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#workspaces button:hover {
|
||||
background-color: @bg_sel;
|
||||
color: @fg;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#workspaces button.focused {
|
||||
background-color: @bg_sel;
|
||||
color: @fg;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#workspaces button.urgent {
|
||||
background-color: @red;
|
||||
color: @bg_dim;
|
||||
}
|
||||
|
||||
/* floating pill style for all modules */
|
||||
#mode,
|
||||
#clock,
|
||||
#battery,
|
||||
#cpu,
|
||||
#memory,
|
||||
#backlight,
|
||||
#wireplumber,
|
||||
#mpris,
|
||||
#bluetooth,
|
||||
#language,
|
||||
#custom-mic,
|
||||
#custom-network,
|
||||
#custom-ssh,
|
||||
#custom-notification,
|
||||
#systemd-failed-units {
|
||||
background-color: @bg;
|
||||
border-radius: 8px;
|
||||
color: @fg;
|
||||
padding: 0 10px;
|
||||
margin: 3px 2px;
|
||||
}
|
||||
|
||||
/* center: alert zone */
|
||||
#custom-ssh.active {
|
||||
color: @cyan;
|
||||
font-size: 18px;
|
||||
padding: 0 10px;
|
||||
margin-top: -2px;
|
||||
margin-bottom: -2px;
|
||||
}
|
||||
|
||||
#custom-notification.notification {
|
||||
color: @yellow;
|
||||
}
|
||||
|
||||
#custom-notification.dnd-notification {
|
||||
color: @red;
|
||||
}
|
||||
|
||||
#custom-notification.dnd-none {
|
||||
color: @gray;
|
||||
}
|
||||
|
||||
#systemd-failed-units {
|
||||
color: @red;
|
||||
}
|
||||
|
||||
/* right: media (hidden by default, visible only when playing/paused) */
|
||||
#mpris {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
min-width: 0;
|
||||
background-color: transparent;
|
||||
color: @green;
|
||||
}
|
||||
|
||||
#mpris.playing {
|
||||
padding: 0 10px;
|
||||
margin: 3px 2px;
|
||||
background-color: @bg;
|
||||
}
|
||||
|
||||
#mpris.paused {
|
||||
padding: 0 10px;
|
||||
margin: 3px 2px;
|
||||
background-color: @bg;
|
||||
color: @gray;
|
||||
}
|
||||
|
||||
/* right: connectivity */
|
||||
#custom-network.disconnected {
|
||||
color: @red;
|
||||
}
|
||||
|
||||
#bluetooth {
|
||||
color: @blue;
|
||||
}
|
||||
|
||||
/* right: system */
|
||||
#cpu {
|
||||
margin-right: 0;
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
#memory {
|
||||
margin-left: 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
/* right: battery */
|
||||
#battery.charging,
|
||||
#battery.plugged {
|
||||
color: @green;
|
||||
}
|
||||
|
||||
#battery.warning:not(.charging) {
|
||||
color: @yellow;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
to {
|
||||
background-color: @red;
|
||||
color: @bg_dim;
|
||||
}
|
||||
}
|
||||
|
||||
#battery.critical:not(.charging) {
|
||||
color: @red;
|
||||
animation-name: blink;
|
||||
animation-duration: 0.5s;
|
||||
animation-timing-function: steps(12);
|
||||
animation-iteration-count: infinite;
|
||||
animation-direction: alternate;
|
||||
}
|
||||
|
||||
/* right: audio */
|
||||
#wireplumber.muted {
|
||||
color: @red;
|
||||
}
|
||||
|
||||
#custom-mic.muted {
|
||||
color: @red;
|
||||
}
|
||||
|
||||
/* right: audio group (volume + mic + brightness) */
|
||||
#wireplumber {
|
||||
margin-right: 0;
|
||||
border-radius: 8px 0 0 8px;
|
||||
}
|
||||
|
||||
#custom-mic {
|
||||
margin-left: 0;
|
||||
margin-right: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
#backlight {
|
||||
margin-left: 0;
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
/* right: clock */
|
||||
#clock {
|
||||
color: @fg;
|
||||
}
|
||||
|
||||
/* mode highlight */
|
||||
#mode {
|
||||
color: @bg_dim;
|
||||
background-color: @yellow;
|
||||
padding: 0 10px;
|
||||
}
|
||||
@@ -1,5 +1,2 @@
|
||||
path=(
|
||||
"$HOME/.local/bin"
|
||||
"$GOPATH/bin"
|
||||
$path
|
||||
)
|
||||
export PATH="/usr/local/bin:/opt/homebrew/bin:$HOME/neovim/bin:$HOME/bin:${PATH}"
|
||||
|
||||
|
||||
11
zsh/.zsh_plugins.txt
Normal file
11
zsh/.zsh_plugins.txt
Normal file
@@ -0,0 +1,11 @@
|
||||
# set up Zsh completions with plugins
|
||||
# mattmc3/ez-compinit
|
||||
zsh-users/zsh-completions kind:fpath path:src
|
||||
|
||||
# frameworks like oh-my-zsh are supported
|
||||
getantidote/use-omz # handle OMZ dependencies
|
||||
ohmyzsh/ohmyzsh path:lib # load OMZ's library
|
||||
ohmyzsh/ohmyzsh path:plugins/git
|
||||
|
||||
# popular fish-like plugins
|
||||
zsh-users/zsh-autosuggestions
|
||||
58
zsh/.zshenv
58
zsh/.zshenv
@@ -4,8 +4,22 @@ export XDG_CACHE_HOME="$HOME/.cache"
|
||||
export XDG_DATA_HOME="$HOME/.local/share"
|
||||
export XDG_STATE_HOME="$HOME/.local/state"
|
||||
|
||||
# deduplicate path
|
||||
typeset -U path
|
||||
# ~/.local/bin
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
# brew sbin
|
||||
export PATH="/opt/homebrew/sbin:$PATH"
|
||||
|
||||
# brew lib
|
||||
export LIBRARY_PATH="$LIBRARY_PATH:/opt/homebrew/lib"
|
||||
|
||||
# cargo
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
source "$HOME/.cargo/env"
|
||||
fi
|
||||
|
||||
# language environment
|
||||
export LANG=en_US.UTF-8
|
||||
|
||||
# editor
|
||||
export EDITOR=nvim
|
||||
@@ -13,27 +27,35 @@ export EDITOR=nvim
|
||||
# go
|
||||
export GOPATH="$XDG_DATA_HOME/go"
|
||||
export GOMODCACHE="$XDG_CACHE_HOME/go/mod"
|
||||
export PATH="$GOPATH/bin:$PATH"
|
||||
|
||||
# ggman
|
||||
export GGROOT="$HOME/git"
|
||||
|
||||
# neovim
|
||||
export PATH="$XDG_DATA_HOME/neovim/bin:$PATH"
|
||||
|
||||
# pyenv
|
||||
export PYENV_ROOT="$XDG_DATA_HOME/.pyenv"
|
||||
command -v pyenv >/dev/null || export PATH="$PYENV_ROOT/bin:$PATH"
|
||||
|
||||
# pyenv-virtualenv
|
||||
# leave python promt to starship
|
||||
export PYENV_VIRTUALENV_DISABLE_PROMPT=1
|
||||
|
||||
# poetry
|
||||
export POETRY_HOME="$XDG_DATA_HOME/poetry"
|
||||
command -v poetry >/dev/null || export PATH="$POETRY_HOME/bin:$PATH"
|
||||
|
||||
# gnupg
|
||||
export GPG_TTY=$(tty)
|
||||
|
||||
# aflabs
|
||||
export USER_UID=$(id -u)
|
||||
export USER_GID=$(id -g)
|
||||
|
||||
# name
|
||||
export NAME="Matej Janežič"
|
||||
|
||||
# starship nest config into a folder, default is ~/.config/starship.toml
|
||||
# starship nest config into a folder, default is ~/.config/startship.toml
|
||||
export STARSHIP_CONFIG="$XDG_CONFIG_HOME/starship/config.toml"
|
||||
|
||||
# claude
|
||||
export CLAUDE_CONFIG_DIR="$XDG_CONFIG_HOME/claude"
|
||||
|
||||
# codex
|
||||
export CODEX_HOME="$XDG_CONFIG_HOME/codex"
|
||||
|
||||
# cliphist
|
||||
export CLIPHIST_DB_PATH="/tmp/cliphist-db"
|
||||
|
||||
# fzf
|
||||
export FZF_DEFAULT_OPTS="--cycle --bind 'tab:toggle-up,btab:toggle-down' --pointer '>' --color '16' --border 'double' --ansi --highlight-line --header 'Navigate with ARROW KEYS or TAB/S-TAB. Select with ENTER.'"
|
||||
|
||||
# ephvm
|
||||
export EPHVM_FLAKE="/home/matej/git/git.janezic.dev/janezicmatej/matej.nix"
|
||||
|
||||
128
zsh/.zshrc
128
zsh/.zshrc
@@ -1,114 +1,46 @@
|
||||
# shell options
|
||||
bindkey -e # emacs line editing
|
||||
unsetopt autocd # don't cd into directories by name
|
||||
setopt NO_BEEP # no terminal bell
|
||||
setopt AUTO_PUSHD # cd pushes to directory stack
|
||||
setopt PUSHD_IGNORE_DUPS # no duplicates in directory stack
|
||||
setopt EXTENDED_GLOB # enable extended glob operators (#, ~, ^)
|
||||
# zsh history file
|
||||
export HISTFILE="$XDG_STATE_HOME/zsh/history"
|
||||
export HISTSIZE=100
|
||||
|
||||
# history
|
||||
HISTFILE="$XDG_STATE_HOME/zsh/history"
|
||||
mkdir -p "${HISTFILE:h}"
|
||||
HISTSIZE=10000
|
||||
SAVEHIST=10000
|
||||
setopt HIST_IGNORE_DUPS # skip consecutive duplicates
|
||||
setopt HIST_IGNORE_SPACE # skip commands starting with space
|
||||
setopt SHARE_HISTORY # share history across sessions
|
||||
setopt HIST_REDUCE_BLANKS # strip extra whitespace
|
||||
## pre plugin load
|
||||
# nvm
|
||||
export NVM_COMPLETION=true
|
||||
# zsh autosugestions
|
||||
export ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE="fg=#ffffff,bg=cyan,bold,underline"
|
||||
|
||||
# -- plugins --
|
||||
# auto-cloned on first launch, auto-updated weekly in background
|
||||
PLUGIN_DIR="$XDG_DATA_HOME/zsh/plugins"
|
||||
_plugin_stamp="$XDG_CACHE_HOME/zsh/plugins-updated"
|
||||
_plugin_needs_update=0
|
||||
# source antidote
|
||||
source $ZDOTDIR/.antidote/antidote.zsh
|
||||
antidote load
|
||||
|
||||
# check if a week (168h) has passed since last update
|
||||
if [[ ! -f "$_plugin_stamp" ]] || [[ -n $_plugin_stamp(#qN.mh+168) ]]; then
|
||||
mkdir -p "${_plugin_stamp:h}"
|
||||
touch "$_plugin_stamp"
|
||||
_plugin_needs_update=1
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
antidote bundle lukechilds/zsh-nvm
|
||||
fi
|
||||
|
||||
function ensure_plugin() {
|
||||
local repo=$1 dir="$PLUGIN_DIR/${1##*/}"
|
||||
# clone if missing, pull if update is due
|
||||
if [[ ! -d "$dir" ]]; then
|
||||
git clone --depth=1 "https://github.com/$repo.git" "$dir"
|
||||
elif (( _plugin_needs_update )); then
|
||||
git -C "$dir" pull --quiet &
|
||||
fi
|
||||
fpath=("$dir" $fpath)
|
||||
source "$dir/${dir##*/}.plugin.zsh" 2>/dev/null
|
||||
}
|
||||
unsetopt autocd
|
||||
|
||||
ensure_plugin zsh-users/zsh-completions
|
||||
ensure_plugin zsh-users/zsh-autosuggestions
|
||||
ensure_plugin romkatv/zsh-no-ps2
|
||||
(( _plugin_needs_update )) && wait
|
||||
ZSH_AUTOSUGGEST_HIGHLIGHT_STYLE="fg=#ffffff,bg=cyan,bold,underline"
|
||||
|
||||
# line editing widgets
|
||||
autoload -Uz up-line-or-beginning-search down-line-or-beginning-search bracketed-paste-magic
|
||||
zle -N up-line-or-beginning-search
|
||||
zle -N down-line-or-beginning-search
|
||||
zle -N bracketed-paste bracketed-paste-magic
|
||||
# only use builtin widgets during paste to avoid autosuggestions conflict
|
||||
zstyle ':bracketed-paste-magic' active-widgets '.self-*'
|
||||
|
||||
# terminal application mode for correct terminfo sequences
|
||||
if (( ${+terminfo[smkx]} && ${+terminfo[rmkx]} )); then
|
||||
autoload -Uz add-zle-hook-widget
|
||||
function zle_application_mode_start { echoti smkx }
|
||||
function zle_application_mode_stop { echoti rmkx }
|
||||
add-zle-hook-widget -Uz zle-line-init zle_application_mode_start
|
||||
add-zle-hook-widget -Uz zle-line-finish zle_application_mode_stop
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
eval "$(pyenv init - zsh)"
|
||||
eval "$(pyenv virtualenv-init - zsh)"
|
||||
fi
|
||||
|
||||
# key bindings
|
||||
[[ -n "${terminfo[kcuu1]}" ]] && bindkey -- "${terminfo[kcuu1]}" up-line-or-beginning-search
|
||||
[[ -n "${terminfo[kcud1]}" ]] && bindkey -- "${terminfo[kcud1]}" down-line-or-beginning-search
|
||||
[[ -n "${terminfo[khome]}" ]] && bindkey -- "${terminfo[khome]}" beginning-of-line
|
||||
[[ -n "${terminfo[kend]}" ]] && bindkey -- "${terminfo[kend]}" end-of-line
|
||||
[[ -n "${terminfo[kdch1]}" ]] && bindkey -- "${terminfo[kdch1]}" delete-char
|
||||
[[ -n "${terminfo[kcbt]}" ]] && bindkey -- "${terminfo[kcbt]}" reverse-menu-complete
|
||||
bindkey -- '^[[1;5C' forward-word # ctrl+right
|
||||
bindkey -- '^[[1;5D' backward-word # ctrl+left
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
export SSH_AUTH_SOCK=$(gpgconf --list-dirs agent-ssh-socket)
|
||||
fi
|
||||
|
||||
# aliases and functions
|
||||
# ggman
|
||||
eval "$(ggman shellrc)"
|
||||
|
||||
# custom functions and aliases
|
||||
source "$ZDOTDIR/aliases.zsh"
|
||||
source "$ZDOTDIR/scripts.zsh"
|
||||
|
||||
# completion
|
||||
fpath=("$XDG_DATA_HOME/zsh/completions" $fpath)
|
||||
fpath=($ZDOTDIR/completions $fpath)
|
||||
autoload -Uz compinit
|
||||
# only regenerate dump once per day
|
||||
local _zcompdump="$XDG_CACHE_HOME/zsh/zcompdump-$ZSH_VERSION"
|
||||
if [[ -n $_zcompdump(#qN.mh+24) ]]; then
|
||||
compinit -d "$_zcompdump"
|
||||
else
|
||||
compinit -C -d "$_zcompdump"
|
||||
fi
|
||||
zstyle ':completion:*' cache-path "$XDG_CACHE_HOME/zsh/zcompcache"
|
||||
zstyle ':completion:*' menu select
|
||||
zstyle ':completion:*' matcher-list 'm:{a-z}={A-Z}'
|
||||
# _git picks these up for the matching git aliases (git/config)
|
||||
_git-sign-branch() {
|
||||
_arguments \
|
||||
'(-n --dry-run)'{-n,--dry-run}'[list the commits that would be signed]' \
|
||||
':branch:__git_branch_names' \
|
||||
':upstream:__git_references'
|
||||
}
|
||||
_git-check-signed() {
|
||||
_arguments ':commit range:__git_commit_ranges'
|
||||
}
|
||||
compinit -d $XDG_CACHE_HOME/zsh/zcompdump-$ZSH_VERSION
|
||||
zstyle ':completion:*' cache-path $XDG_CACHE_HOME/zsh/zcompcache
|
||||
|
||||
# opam configuration
|
||||
# [[ ! -r /Users/janezicmatej/.opam/opam-init/init.zsh ]] || source /Users/janezicmatej/.opam/opam-init/init.zsh > /dev/null 2> /dev/null
|
||||
|
||||
# tool hooks
|
||||
eval "$(ggman shellrc)"
|
||||
eval "$(starship init zsh)"
|
||||
eval "$(direnv hook zsh)"
|
||||
# skip direnv in subshells; completion functions cd inside $(...) and trip chpwd
|
||||
_direnv_hook_toplevel() { (( ZSH_SUBSHELL )) || _direnv_hook }
|
||||
chpwd_functions=(${(u)chpwd_functions/#%_direnv_hook/_direnv_hook_toplevel})
|
||||
|
||||
# gnupg
|
||||
export GPG_TTY=$(tty)
|
||||
|
||||
@@ -1,78 +1,41 @@
|
||||
alias z="exec zsh"
|
||||
alias t=tmux_attach
|
||||
alias e="bash /home/matej/git/git.janezic.dev/janezicmatej/matej.nix/scripts/ephvm-run.sh"
|
||||
alias sb="/home/matej/git/git.aflabs.org/internal-resources/sandbox-vm/scripts/run.sh"
|
||||
|
||||
alias n=nvim
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
alias n=nvim_ve
|
||||
alias vim=nvim_ve
|
||||
elif [[ "$OSTYPE" == "linux"* ]]; then
|
||||
alias n=nvim
|
||||
fi
|
||||
|
||||
alias s="~/.config/bin/ssh-menu"
|
||||
|
||||
# notes across ~/git and ~/.config, see claude/rules/notes.md
|
||||
alias notes='NOTES_ROOTS="$GGROOT:$HOME/.config" python3 ~/.config/claude/skills/notes/notes.py'
|
||||
alias s="ssh-menu"
|
||||
|
||||
# docker
|
||||
alias dp="docker ps --format 'table {{.Names}}\t{{.Ports}}'"
|
||||
alias dcp="docker compose ps --format 'table {{.Name}}\t{{.Ports}}'"
|
||||
|
||||
# navigation
|
||||
alias cdgit='cd $GGROOT'
|
||||
alias cdgit="cd $GGROOT"
|
||||
alias icloud="cd $HOME/Library/Mobile\ Documents/com\~apple\~CloudDocs"
|
||||
alias temp="cd $HOME/Desktop/temp"
|
||||
|
||||
# git status/diff
|
||||
alias gst="git status"
|
||||
alias gd="git diff"
|
||||
alias gds="git diff --staged"
|
||||
alias gdup="git diff @{upstream}"
|
||||
alias glog="git log --oneline --decorate --graph"
|
||||
alias gloga="git log --oneline --decorate --graph --all"
|
||||
# apple specific
|
||||
alias accenton="defaults write -g ApplePressAndHoldEnabled -bool true"
|
||||
alias accentoff="defaults write -g ApplePressAndHoldEnabled -bool false"
|
||||
|
||||
# git
|
||||
alias gros="git reset origin/$(git_current_branch) --soft"
|
||||
alias gcan="git commit -v --amend --no-edit"
|
||||
alias glogaa="git log --oneline --graph --all --pretty=format:\"%C(auto)%h %C(blue)(%aL/%cL)%C(auto)%(decorate) %s%Creset\""
|
||||
|
||||
# git staging/commit
|
||||
alias ga="git add"
|
||||
alias gc="git commit -v"
|
||||
alias gc!="git commit -v --amend"
|
||||
alias gcan="git commit -v --amend --no-edit"
|
||||
alias gcan!="git commit -v --all --no-edit --amend"
|
||||
alias grs="git restore"
|
||||
|
||||
# git branch/checkout
|
||||
alias gb="git branch"
|
||||
alias gco="git checkout"
|
||||
alias gcm='git checkout $(git_main_branch)'
|
||||
alias gsw="git switch"
|
||||
|
||||
# git fetch/pull/push
|
||||
alias gfa="git fetch --all --prune --tags"
|
||||
alias gl="git pull"
|
||||
alias gp="git push"
|
||||
alias gpf="git push --force-with-lease"
|
||||
alias gpf!="git push --force"
|
||||
alias gpo="git push origin"
|
||||
|
||||
# git rebase
|
||||
alias grb="git rebase"
|
||||
alias grba="git rebase --abort"
|
||||
alias grbc="git rebase --continue"
|
||||
alias grbi="git rebase -i"
|
||||
grbm() { git rebase "$(git_main_branch)" }
|
||||
|
||||
# git merge
|
||||
alias gm="git merge"
|
||||
alias gma="git merge --abort"
|
||||
alias gmc="git merge --continue"
|
||||
|
||||
# git cherry-pick
|
||||
alias gcp="git cherry-pick"
|
||||
alias gcpa="git cherry-pick --abort"
|
||||
alias gcpc="git cherry-pick --continue"
|
||||
|
||||
# git stash/reset
|
||||
alias gstp="git stash pop"
|
||||
alias grhh="git reset --hard HEAD"
|
||||
gros() { git reset "origin/$(git_current_branch)" --soft }
|
||||
groh() { git reset "origin/$(git_current_branch)" --hard }
|
||||
|
||||
# git navigation
|
||||
alias grt='cd "$(git rev-parse --show-toplevel || echo .)"'
|
||||
# unsorted
|
||||
alias ci="glab ci list"
|
||||
alias bm=batman
|
||||
alias grow="~/.local/bin/cbonsai -liWC -M 15 -t 2 -w 60 -s $(date +%s)"
|
||||
alias clean_ds_store='find . -name ".DS_Store" -type f -delete -print'
|
||||
|
||||
# ruff
|
||||
alias ruffme="ruff check --fix && ruff format"
|
||||
|
||||
# clipboard
|
||||
alias xcp="xclip -selection c"
|
||||
|
||||
0
zsh/completions/.keep
Normal file
0
zsh/completions/.keep
Normal file
51
zsh/get-dump
Executable file
51
zsh/get-dump
Executable file
@@ -0,0 +1,51 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function get-dump {
|
||||
if [[ -z $1 ]]; then
|
||||
host="$(rg -N --no-heading 'Host .*' ~/.ssh \
|
||||
| sed 's/Host \(.*\)/\1/' \
|
||||
| fzf --cycle \
|
||||
--bind 'tab:toggle-up,btab:toggle-down' \
|
||||
--delimiter ':' \
|
||||
--with-nth 2 \
|
||||
--header "Navigate with ARROW KEYS or TAB/S-TAB. Select with ENTER." \
|
||||
--border "double" \
|
||||
--border-label "get-dump" \
|
||||
--ansi \
|
||||
--highlight-line \
|
||||
| cut -d ":" -f2
|
||||
)"
|
||||
if [[ -z $host ]]; then
|
||||
return
|
||||
fi
|
||||
else
|
||||
host=$1
|
||||
fi
|
||||
|
||||
selected=$(ssh "$host" docker ps --format "{{.Names}}" \
|
||||
| fzf --cycle \
|
||||
--bind 'tab:toggle-up,btab:toggle-down' \
|
||||
--header "Navigate with ARROW KEYS or TAB/S-TAB. Select with ENTER." \
|
||||
--border "double" \
|
||||
--border-label "get-dump" \
|
||||
--ansi \
|
||||
--highlight-line \
|
||||
)
|
||||
|
||||
if [[ -z $selected ]]; then
|
||||
echo "not provided"
|
||||
return
|
||||
fi
|
||||
|
||||
if ! [[ -d "./dumps" ]]; then
|
||||
mkdir "./dumps"
|
||||
fi
|
||||
|
||||
location="./dumps/${host}_${selected}_$(date +'%s')"
|
||||
|
||||
echo "dumping to $location"
|
||||
|
||||
ssh "$host" docker exec "$selected" pg_dump -U db --format=c db >"$location"
|
||||
}
|
||||
|
||||
get-dump "$@"
|
||||
@@ -1,32 +1,94 @@
|
||||
# git helpers for aliases
|
||||
function git_current_branch {
|
||||
command git rev-parse --abbrev-ref HEAD 2>/dev/null
|
||||
function nvim_ve {
|
||||
if [[ -e $VIRTUAL_ENV && -f "$VIRTUAL_ENV/bin/activate" ]]; then
|
||||
source "$VIRTUAL_ENV/bin/activate"
|
||||
command nvim $@
|
||||
deactivate
|
||||
else
|
||||
command nvim $@
|
||||
fi
|
||||
}
|
||||
|
||||
function git_main_branch {
|
||||
command git rev-parse --git-dir &>/dev/null || return
|
||||
local ref
|
||||
for ref in refs/{heads,remotes/{origin,upstream}}/{main,master,trunk}; do
|
||||
if command git show-ref -q --verify "$ref"; then
|
||||
echo ${ref:t}
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
echo main
|
||||
function afm {
|
||||
RES=$(curl -s "https://$1/api/monitoring/requirements")
|
||||
COUNT=$(echo "$RES" | grep -c $2)
|
||||
if [[ $COUNT -ge 0 ]]; then
|
||||
VER=$(echo "$RES" | jq '.[] | select(.name | contains("'"$2"'")).version')
|
||||
# OP=$(echo "$RES" | jq '.[] | select(.name | contains("'"$2"'")).requirement?[0][0]')
|
||||
# echo "$2""$(echo "$OP" | tr -d '"')""$(echo "$VER" | tr -d '"')"
|
||||
echo "${2}==$(echo "$VER" | tr -d '"')"
|
||||
fi
|
||||
}
|
||||
|
||||
function nukepip {
|
||||
pip uninstall $(pip freeze) -y
|
||||
pip install -r $(pyenv root)/default-packages
|
||||
}
|
||||
|
||||
function pyinit {
|
||||
if [[ -f .python-version ]]; then
|
||||
echo "found .python-version, stopping"
|
||||
return
|
||||
fi
|
||||
|
||||
DIRNAME=$(basename "$PWD")
|
||||
|
||||
if [[ $(pyenv versions | grep -E "/${DIRNAME}$") ]]; then
|
||||
echo "found existing version with this name, setting..."
|
||||
pyenv local "$DIRNAME"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ -z $1 ]]; then
|
||||
echo "no python version provided, defaulting to $(pyenv version-name)"
|
||||
VERSION=$(pyenv version-name)
|
||||
else
|
||||
VERSION=$1
|
||||
fi
|
||||
|
||||
pyenv virtualenv "$VERSION" "$DIRNAME"
|
||||
pyenv local "$DIRNAME"
|
||||
|
||||
}
|
||||
|
||||
function lh {
|
||||
if [[ -z $1 ]]; then
|
||||
PORT=7999
|
||||
else
|
||||
PORT=$1
|
||||
fi
|
||||
|
||||
open "http://localhost:$PORT"
|
||||
}
|
||||
|
||||
function dps {
|
||||
if [[ -z $1 ]]; then
|
||||
docker ps --format table'{{ .ID }}\t{{ .Image }}\t{{ .Ports}}\t{{ .Names }}'
|
||||
else
|
||||
docker ps --format table'{{ .ID }}\t{{ .Image }}\t{{ .Ports}}\t{{ .Names }}' | grep $1
|
||||
fi
|
||||
}
|
||||
|
||||
function tssh {
|
||||
if [[ -z $1 ]]; then
|
||||
echo "provide ssh host"
|
||||
return
|
||||
fi
|
||||
|
||||
ssh -t "$@" "command -v tmux && (tmux a || tmux new-session -s gorazd -c ~) || bash"
|
||||
}
|
||||
|
||||
|
||||
function tmux_attach {
|
||||
local tmux_running
|
||||
tmux_running=$(pgrep tmux)
|
||||
|
||||
if [[ -n ${TMUX} ]]; then
|
||||
if ! [[ -z ${TMUX} ]]; then
|
||||
echo "already attached; refreshing env"
|
||||
source <(tmux show-environment | sed -n 's/^\(.*\)=\(.*\)$/export \1="\2"/p')
|
||||
return 0
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ -z $tmux_running ]]; then
|
||||
~/.config/tmux/scripts/tmux-sessionizer ~
|
||||
~/.config/tmux/tmux-sessionizer ~
|
||||
else
|
||||
tmux a
|
||||
fi
|
||||
|
||||
75
zsh/ssh-menu
Executable file
75
zsh/ssh-menu
Executable file
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
function _ssh_menu_preview {
|
||||
file=$1; shift
|
||||
host=$1; shift
|
||||
|
||||
j=$(cat $file \
|
||||
| jc --ssh-conf \
|
||||
| jq -r ".[] \
|
||||
| select(.host == \"$host\") \
|
||||
| .jumps = (\
|
||||
if (.proxyjump | type == \"array\" and length > 0) then \
|
||||
(.proxyjump | join(\",\")) \
|
||||
else \
|
||||
\"null\" \
|
||||
end \
|
||||
)" \
|
||||
)
|
||||
|
||||
user=$(jq -r '.user' <<<$j)
|
||||
port=$(jq -r '.port' <<<$j)
|
||||
hostname=$(jq -r '.hostname' <<<$j)
|
||||
jumps=$(jq -r '.jumps' <<<$j)
|
||||
|
||||
|
||||
echo "$(cat <<EOF
|
||||
User $user
|
||||
HostName $hostname\
|
||||
$([[ $port != "null" ]] && echo -e "\nPort $port")\
|
||||
$([[ $jumps != "null" ]] && echo -e "\nProxyJump $jumps")
|
||||
|
||||
command:
|
||||
ssh $user@$hostname$([[ $port != "null" ]] && echo -n " -p $port")$([[ $jumps != "null" ]] && echo -n " -J $jumps")
|
||||
EOF
|
||||
)"
|
||||
|
||||
echo ""
|
||||
|
||||
if command -v host 2>&1 >/dev/null; then
|
||||
host $hostname
|
||||
echo ""
|
||||
fi
|
||||
|
||||
ping -c 1 -W 1 $hostname &>/dev/null \
|
||||
&& echo "Host is reachable!" \
|
||||
|| echo "Host is not reachable!"
|
||||
}
|
||||
|
||||
export -f _ssh_menu_preview
|
||||
|
||||
function ssh-menu {
|
||||
selected="$(rg -N --no-heading '^Host .*' ~/.ssh \
|
||||
| sed 's/Host \(.*\)/\1/' | sort \
|
||||
| SHELL=$(which bash) fzf --cycle \
|
||||
--bind 'tab:toggle-up,btab:toggle-down' \
|
||||
--delimiter ':' \
|
||||
--with-nth 2 \
|
||||
--preview "_ssh_menu_preview {1} {2}" \
|
||||
--preview-label "ssh config info" \
|
||||
--header "Navigate with ARROW KEYS or TAB/S-TAB. Select with ENTER." \
|
||||
--border "double" \
|
||||
--border-label "ssh-menu" \
|
||||
--ansi \
|
||||
--highlight-line \
|
||||
| cut -d ":" -f2
|
||||
)"
|
||||
|
||||
if [[ -z $selected ]]; then
|
||||
return
|
||||
fi
|
||||
|
||||
ssh "$selected"
|
||||
}
|
||||
|
||||
ssh-menu
|
||||
Reference in New Issue
Block a user