Initial import: chezmoi source for Omarchy (Hyprland)
Imported from prdlk/dotfiles master (efdd827), which supersedes the omarchy branch (3031416). Drops the Umbriel, Noctalia, niri, CachyOS, macOS, and Blume docs-site layers; docs/ is plain Markdown.
This commit is contained in:
commit
b18729f4a0
163 files changed
+12685
No files matched your search
Executable
+11
@@ -0,0 +1,11 @@
|
||||
#!/bin/sh
|
||||
# Choose and launch an AI CLI via gum.
|
||||
choice=$(gum choose --header 'AI CLI' claude opencode codex hermes fractal) || exit 0
|
||||
[ -n "$choice" ] || exit 0
|
||||
|
||||
case "$choice" in
|
||||
claude) exec claude --dangerously-skip-permissions ;;
|
||||
# labels match the binary names, so just run it;
|
||||
# add a dedicated case here if another tool needs flags
|
||||
*) exec "$choice" ;;
|
||||
esac
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env python3
|
||||
"""atuin-pick: atuin history picker for a kitty split (ctrl+shift+p).
|
||||
|
||||
atuin search -i draws its TUI on the tty and prints the selected command
|
||||
to stdout (Esc prints nothing). Filter to history from the current
|
||||
directory. Hand the selection to an interactive fish via --init-command:
|
||||
it runs inside that shell (aliases/abbr resolve, cd persists) with its
|
||||
output in the split, and the prompt drops down below it.
|
||||
"""
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
# kitty's launch env has no ATUIN_SESSION (atuin's shell init never ran), so
|
||||
# seed it here. atuin itself comes from the package manager, on PATH.
|
||||
atuin = "atuin"
|
||||
if "ATUIN_SESSION" not in os.environ:
|
||||
os.environ["ATUIN_SESSION"] = subprocess.run(
|
||||
[atuin, "uuid"], stdout=subprocess.PIPE, text=True
|
||||
).stdout.strip()
|
||||
|
||||
sel = subprocess.run(
|
||||
[atuin, "search", "-i", "--filter-mode", "directory"],
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
).stdout.strip()
|
||||
|
||||
argv = ["fish"]
|
||||
if sel:
|
||||
argv += ["-C", f"echo {shlex.quote('\u276f ' + sel)}; {sel}"]
|
||||
os.execvp("fish", argv)
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
# brave-new-tab URL
|
||||
# Open URL in a new tab of the running Brave Origin (its last active window),
|
||||
# or start Brave Origin with it. Detached, so the caller never blocks.
|
||||
# Without a scheme, localhost, *.localhost, *.local, and 127.x get http://;
|
||||
# anything else gets https://.
|
||||
set -eu
|
||||
|
||||
[ $# -eq 1 ] || { echo "usage: brave-new-tab URL" >&2; exit 2; }
|
||||
|
||||
url=$1
|
||||
case $url in
|
||||
*://* | about:*) ;;
|
||||
*)
|
||||
host=${url%%[/?#]*}
|
||||
host=$(printf '%s' "${host%:*}" | tr '[:upper:]' '[:lower:]')
|
||||
case $host in
|
||||
localhost | *.localhost | *.local | 127.*) url="http://$url" ;;
|
||||
*) url="https://$url" ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
|
||||
exec setsid -f brave-origin --new-tab "$url" >/dev/null 2>&1
|
||||
Executable
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/sh
|
||||
# Code2Prompt with fuzzy-search template.
|
||||
tpl=$(fd --type f --extension hbs . "$HOME/.config/code2prompt" | fzf) || exit 0
|
||||
[ -n "$tpl" ] || exit 0
|
||||
exec code2prompt --template "$tpl"
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/bin/sh
|
||||
# Fuzzy-pick a desktop app ID and copy to clipboard.
|
||||
existing_dirs=""
|
||||
for d in /usr/share/applications \
|
||||
/usr/local/share/applications \
|
||||
"$HOME/.local/share/applications" \
|
||||
/var/lib/flatpak/exports/share/applications \
|
||||
"$HOME/.local/share/flatpak/exports/share/applications" \
|
||||
/var/lib/snapd/desktop/applications; do
|
||||
[ -d "$d" ] && existing_dirs="$existing_dirs $d"
|
||||
done
|
||||
|
||||
if [ -z "$existing_dirs" ]; then
|
||||
echo "No application directories found." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# word splitting of $existing_dirs is intended
|
||||
# shellcheck disable=SC2086
|
||||
chosen=$(
|
||||
fd --type f --extension desktop . $existing_dirs \
|
||||
| while IFS= read -r f; do
|
||||
app_id=$(basename "$f" .desktop)
|
||||
name=$(sed -n 's/^Name=//p' "$f" | head -n1)
|
||||
[ -z "$name" ] && name=$app_id
|
||||
printf '%s\t%s\n' "$name" "$app_id"
|
||||
done \
|
||||
| sort -u -t "$(printf '\t')" -k1 \
|
||||
| fzf \
|
||||
--delimiter='\t' \
|
||||
--with-nth=1,2 \
|
||||
--prompt="App ID › " \
|
||||
--height=50% \
|
||||
--layout=reverse \
|
||||
--border=rounded \
|
||||
--info=inline
|
||||
)
|
||||
|
||||
[ -n "$chosen" ] || exit 0
|
||||
|
||||
app_id=$(printf '%s\n' "$chosen" | cut -f2)
|
||||
printf '%s' "$app_id" | wl-copy
|
||||
echo "Copied: $app_id"
|
||||
Executable
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Copy all AGENTS.md files to a target directory while preserving structure.
|
||||
out_dir=$1
|
||||
|
||||
if [ -z "$out_dir" ]; then
|
||||
echo "Error: Output directory required." >&2
|
||||
echo "Usage: extract_agents <absolute_or_relative_output_path>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$out_dir"
|
||||
|
||||
# Find and copy preserving relative paths
|
||||
fd -t f -g "AGENTS.md" -x cp --parents "{}" "$out_dir"
|
||||
|
||||
echo "Extraction complete. Check $out_dir."
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/bin/sh
|
||||
# Fuzzy find and execute a Fabric pattern.
|
||||
json_file=$HOME/.config/fabric/pattern_explainations.json
|
||||
|
||||
if [ ! -f "$json_file" ]; then
|
||||
printf '\033[31mError: %s not found.\033[0m\n' "$json_file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# fabric is aliased to fabric-ai in the shells; resolve the real binary here
|
||||
if command -v fabric-ai >/dev/null 2>&1; then
|
||||
fabric_bin=fabric-ai
|
||||
else
|
||||
fabric_bin=fabric
|
||||
fi
|
||||
|
||||
# 1. Safely capture piped input (if any) to a temp file — preserves all
|
||||
# formatting and newlines, avoids variable escaping issues
|
||||
temp_input=$(mktemp)
|
||||
trap 'rm -f "$temp_input"' EXIT INT TERM
|
||||
|
||||
if [ ! -t 0 ]; then
|
||||
cat > "$temp_input"
|
||||
fi
|
||||
|
||||
# 2. Parse JSON to TSV -> fzf (name in list, description in preview)
|
||||
selection=$(jq -r '.[] | "\(.name)\t\(.description)"' "$json_file" \
|
||||
| fzf --delimiter '\t' \
|
||||
--with-nth 1 \
|
||||
--preview 'echo {2}' \
|
||||
--preview-window 'right:60%:wrap' \
|
||||
--prompt 'Pattern > ' \
|
||||
--info inline \
|
||||
--bind 'ctrl-/:toggle-preview')
|
||||
|
||||
# 3. Execute with or without the piped data
|
||||
if [ -n "$selection" ]; then
|
||||
pattern=$(printf '%s\n' "$selection" | cut -f1)
|
||||
if [ -s "$temp_input" ]; then
|
||||
"$fabric_bin" --pattern "$pattern" < "$temp_input"
|
||||
else
|
||||
"$fabric_bin" --pattern "$pattern"
|
||||
fi
|
||||
fi
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
#!/bin/sh
|
||||
# Discard all git changes with gum confirmation.
|
||||
if gum confirm "Discard all uncommitted changes? This is irreversible."; then
|
||||
git reset --hard HEAD
|
||||
git clean -fd
|
||||
echo "Changes wiped."
|
||||
else
|
||||
echo "Operation aborted."
|
||||
fi
|
||||
Executable
+163
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env bash
|
||||
# gh-sonr-org-migrate
|
||||
# Migrates all repos from orgs matching "sonr" into sonr-io,
|
||||
# prompting via gum for renames on collision, then deletes emptied orgs.
|
||||
#
|
||||
# Requires: gh (authenticated as org owner on all orgs), gum, jq
|
||||
|
||||
TARGET_ORG=sonr-io
|
||||
|
||||
red() { printf '\033[31m%s\033[0m\n' "$*"; }
|
||||
green() { printf '\033[32m%s\033[0m\n' "$*"; }
|
||||
yellow() { printf '\033[33m%s\033[0m\n' "$*"; }
|
||||
cyan() { printf '\033[36m%s\033[0m\n' "$*"; }
|
||||
bcyan() { printf '\033[1;36m%s\033[0m\n' "$*"; }
|
||||
bgreen() { printf '\033[1;32m%s\033[0m\n' "$*"; }
|
||||
|
||||
# ── Preflight ────────────────────────────────────────────────────────────────
|
||||
for bin in gh gum jq; do
|
||||
if ! command -v "$bin" >/dev/null 2>&1; then
|
||||
red "missing dependency: $bin"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
if ! gh auth status >/dev/null 2>&1; then
|
||||
red "gh is not authenticated"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── Discover source orgs ─────────────────────────────────────────────────────
|
||||
# All orgs the user belongs to whose login contains "sonr", excluding target.
|
||||
mapfile -t source_orgs < <(gh api user/orgs --paginate --jq '.[].login' \
|
||||
| grep 'sonr' | grep -vx "$TARGET_ORG")
|
||||
|
||||
if [ "${#source_orgs[@]}" -eq 0 ]; then
|
||||
echo "No source orgs found. Nothing to do."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cyan "Source orgs:"
|
||||
printf ' %s\n' "${source_orgs[@]}"
|
||||
cyan "Target org:"
|
||||
echo " $TARGET_ORG"
|
||||
echo
|
||||
|
||||
# Let the user confirm / trim the list interactively
|
||||
mapfile -t selected_orgs < <(printf '%s\n' "${source_orgs[@]}" | gum choose --no-limit \
|
||||
--header "Select orgs to migrate into $TARGET_ORG (space to toggle, enter to confirm)")
|
||||
|
||||
if [ "${#selected_orgs[@]}" -eq 0 ]; then
|
||||
echo "Nothing selected. Exiting."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
repo_exists_in_target() {
|
||||
gh api "repos/$TARGET_ORG/$1" --silent >/dev/null 2>&1
|
||||
}
|
||||
|
||||
transfer_repo() {
|
||||
# $1 = source org, $2 = repo name, $3 = (optional) new name
|
||||
local src=$1 repo=$2 new_name=${3:-}
|
||||
local args=(-f "new_owner=$TARGET_ORG")
|
||||
if [ -n "$new_name" ]; then
|
||||
args+=(-f "new_name=$new_name")
|
||||
fi
|
||||
gh api -X POST "repos/$src/$repo/transfer" "${args[@]}" --silent
|
||||
}
|
||||
|
||||
# ── Migration ────────────────────────────────────────────────────────────────
|
||||
failed_transfers=()
|
||||
|
||||
for org in "${selected_orgs[@]}"; do
|
||||
echo
|
||||
bcyan "── $org ──"
|
||||
|
||||
mapfile -t repos < <(gh api "orgs/$org/repos" --paginate --jq '.[].name')
|
||||
|
||||
if [ "${#repos[@]}" -eq 0 ]; then
|
||||
echo " (no repos)"
|
||||
continue
|
||||
fi
|
||||
|
||||
for repo in "${repos[@]}"; do
|
||||
new_name=""
|
||||
|
||||
if repo_exists_in_target "$repo"; then
|
||||
yellow " ⚠ $repo already exists in $TARGET_ORG"
|
||||
while true; do
|
||||
new_name=$(gum input \
|
||||
--header "New name for $org/$repo (exists in $TARGET_ORG)" \
|
||||
--placeholder "$org-$repo" \
|
||||
--value "$org-$repo")
|
||||
if [ -z "$new_name" ]; then
|
||||
echo " skipped $org/$repo"
|
||||
new_name=__SKIP__
|
||||
break
|
||||
fi
|
||||
if repo_exists_in_target "$new_name"; then
|
||||
yellow " $new_name also exists — pick another"
|
||||
else
|
||||
break
|
||||
fi
|
||||
done
|
||||
[ "$new_name" = __SKIP__ ] && continue
|
||||
fi
|
||||
|
||||
label="$org/$repo → $TARGET_ORG/${new_name:-$repo}"
|
||||
if transfer_repo "$org" "$repo" "$new_name"; then
|
||||
green " ✓ $label"
|
||||
else
|
||||
red " ✗ failed: $label"
|
||||
failed_transfers+=("$org/$repo")
|
||||
fi
|
||||
done
|
||||
done
|
||||
|
||||
if [ "${#failed_transfers[@]}" -gt 0 ]; then
|
||||
echo
|
||||
red "Failed transfers (orgs containing these will NOT be deleted):"
|
||||
printf ' %s\n' "${failed_transfers[@]}"
|
||||
fi
|
||||
|
||||
# ── Wait for transfers to settle ─────────────────────────────────────────────
|
||||
# GitHub repo transfers are asynchronous; give them time to drain.
|
||||
echo
|
||||
gum spin --title "Waiting 30s for transfers to settle..." -- sleep 30
|
||||
|
||||
# ── Delete emptied orgs ──────────────────────────────────────────────────────
|
||||
echo
|
||||
for org in "${selected_orgs[@]}"; do
|
||||
# Skip orgs with failed transfers
|
||||
org_had_failure=0
|
||||
for f in ${failed_transfers[@]+"${failed_transfers[@]}"}; do
|
||||
case "$f" in
|
||||
"$org"/*) org_had_failure=1; break ;;
|
||||
esac
|
||||
done
|
||||
if [ "$org_had_failure" -eq 1 ]; then
|
||||
yellow "skipping delete: $org (had failures)"
|
||||
continue
|
||||
fi
|
||||
|
||||
remaining=$(gh api "orgs/$org/repos" --paginate --jq '. | length' | jq -s 'add // 0')
|
||||
|
||||
if [ "$remaining" -gt 0 ]; then
|
||||
yellow "skipping delete: $org still has $remaining repo(s) (transfer pending?)"
|
||||
continue
|
||||
fi
|
||||
|
||||
if gum confirm "Delete empty org $org?"; then
|
||||
if gh api -X DELETE "orgs/$org" --silent; then
|
||||
green "✓ deleted $org"
|
||||
else
|
||||
red "✗ could not delete $org (API delete requires org owner; may need manual deletion in Settings)"
|
||||
fi
|
||||
else
|
||||
echo "kept $org"
|
||||
fi
|
||||
done
|
||||
|
||||
echo
|
||||
bgreen "Done."
|
||||
Executable
+116
@@ -0,0 +1,116 @@
|
||||
#!/bin/sh
|
||||
# ghq get + resolve the on-disk clone path; prints the repo directory.
|
||||
# Shell wrappers (fish/bash/zsh `ghg` functions) cd into the printed path —
|
||||
# a child process can't change the caller's cwd.
|
||||
#
|
||||
# Then sets up a prad.codes mirror when the repo has none (best effort; the
|
||||
# clone never fails over it):
|
||||
# github.com/<owner>/<name> you admin → pull mirror into a prad.codes org
|
||||
# (picked with gum, guessed from the language; esc skips), every 8h
|
||||
# prad.codes/<org>/<name> that isn't itself a mirror → push mirror to
|
||||
# github.com/<you>/<name> (created if missing), on every push and every 8h
|
||||
repo=$1
|
||||
if [ -z "$repo" ]; then
|
||||
echo "usage: ghg-clone <repo>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ghq natively understands https://, ssh://, git@host:…, and host/user/repo.
|
||||
# Keep its output on stderr so stdout stays clean for the wrapper's cd.
|
||||
ghq get "$repo" >&2 || exit
|
||||
|
||||
# Reduce the input to ghq's on-disk shape: <host>/<user>/<repo> —
|
||||
# strip scheme (https://, ssh://, git://), user@ (git@github.com:…),
|
||||
# turn scp host:path into host/path, drop trailing .git.
|
||||
rel=$(printf '%s\n' "$repo" \
|
||||
| sed -E 's#^[[:alnum:]]+://##; s#^[^@/]+@##; s#:#/#; s#\.git$##')
|
||||
|
||||
# bare "user/repo" with no host → default to github.com
|
||||
case "$rel" in
|
||||
*.*/*) ;;
|
||||
*) rel=github.com/$rel ;;
|
||||
esac
|
||||
|
||||
dir=$(ghq root)/$rel
|
||||
if [ ! -d "$dir" ]; then
|
||||
echo "ghg: expected clone at $dir but it does not exist" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
command -v zoxide >/dev/null 2>&1 && zoxide add "$dir"
|
||||
|
||||
# ── Mirrors ───────────────────────────────────────────────────────────────
|
||||
# Everything below talks on stderr; stdout carries only the final path.
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
# prad.codes orgs are per language; guess one from GitHub's primary language.
|
||||
lang_org() {
|
||||
case $1 in
|
||||
Go) echo go ;;
|
||||
Python | "Jupyter Notebook") echo py ;;
|
||||
Rust) echo rs ;;
|
||||
Dart) echo dart ;;
|
||||
TeX) echo tex ;;
|
||||
JavaScript | TypeScript | Astro | Svelte | Vue | HTML | CSS) echo js ;;
|
||||
Markdown | MDX) echo md ;;
|
||||
*) echo env ;; # Lua, Shell, no code at all (configs)
|
||||
esac
|
||||
}
|
||||
|
||||
pull_mirror() { # owner name
|
||||
info=$(gh repo view "$1/$2" --json viewerPermission,isPrivate,primaryLanguage \
|
||||
-q '[.viewerPermission, .isPrivate, .primaryLanguage.name // ""] | @tsv' 2>/dev/null) || return 0
|
||||
perm=$(printf '%s\n' "$info" | cut -f1)
|
||||
private=$(printf '%s\n' "$info" | cut -f2)
|
||||
lang=$(printf '%s\n' "$info" | cut -f3)
|
||||
[ "$perm" = ADMIN ] || return 0 # someone else's repo: just a clone
|
||||
# tea api exits 0 even on errors, hence jq -e on every response.
|
||||
tea api "/api/v1/repos/search?q=$2&limit=50" | jq -e --arg u "github.com/$1/$2" \
|
||||
'any(.data[]; (.original_url // "") | ascii_downcase | sub("(\\.git)?/?$"; "") | endswith($u | ascii_downcase))' \
|
||||
>/dev/null && return 0
|
||||
org=$(tea api '/api/v1/orgs?limit=50' | jq -r '.[].username' |
|
||||
gum choose --header "Pull-mirror github.com/$1/$2 into which prad.codes org? (esc skips)" \
|
||||
--selected "$(lang_org "$lang")") || return 0
|
||||
[ -n "$org" ] || return 0
|
||||
echo "→ pull mirror: github.com/$1/$2 → prad.codes/$org/$2 (every 8h)" >&2
|
||||
priv=
|
||||
[ "$private" = true ] && priv=--private
|
||||
# shellcheck disable=SC2086 # $priv is one optional flag
|
||||
tea repos migrate --name "$2" --owner "$org" --clone-url "https://github.com/$1/$2.git" \
|
||||
--service git --mirror --mirror-interval 8h --auth-token "$(gh auth token)" $priv >&2 ||
|
||||
echo "ghg: pull mirror failed" >&2
|
||||
}
|
||||
|
||||
push_mirror() { # org name
|
||||
repo=$(tea api "/api/v1/repos/$1/$2")
|
||||
printf '%s' "$repo" | jq -e '.id and (.mirror | not)' >/dev/null || return 0
|
||||
tea api "/api/v1/repos/$1/$2/push_mirrors" | jq -e 'length == 0' >/dev/null || return 0
|
||||
me=$(gh api user -q .login 2>/dev/null) || return 0
|
||||
if gh repo view "$me/$2" >/dev/null 2>&1; then
|
||||
gum confirm "Push-mirror prad.codes/$1/$2 to the existing github.com/$me/$2? Mirror pushes overwrite it." || return 0
|
||||
else
|
||||
gum confirm "Push-mirror prad.codes/$1/$2 to a new github.com/$me/$2?" || return 0
|
||||
vis=--public
|
||||
[ "$(printf '%s' "$repo" | jq -r .private)" = true ] && vis=--private
|
||||
gh repo create "$me/$2" "$vis" --description "Mirror of https://prad.codes/$1/$2" >&2 || return 0
|
||||
fi
|
||||
if jq -n --arg url "https://github.com/$me/$2.git" --arg user "$me" --arg pass "$(gh auth token)" \
|
||||
'{remote_address: $url, remote_username: $user, remote_password: $pass,
|
||||
interval: "8h0m0s", sync_on_commit: true}' |
|
||||
tea api -d @- "/api/v1/repos/$1/$2/push_mirrors" | jq -e .remote_name >/dev/null; then
|
||||
tea api -X POST "/api/v1/repos/$1/$2/push_mirrors-sync" >/dev/null # first push now
|
||||
echo "→ push mirror: prad.codes/$1/$2 → github.com/$me/$2 (on push + every 8h)" >&2
|
||||
else
|
||||
echo "ghg: push mirror failed" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
if have gh && have tea && have jq && have gum; then
|
||||
owner=${rel#*/} name=${owner#*/} owner=${owner%%/*}
|
||||
case $rel in
|
||||
github.com/*/*) pull_mirror "$owner" "$name" ;;
|
||||
prad.codes/*/*) push_mirror "$owner" "$name" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
printf '%s\n' "$dir"
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/sh
|
||||
# Create a new private repo (prad.codes via tea, or github.com via gh),
|
||||
# clone it with ghq, and print the clone directory for the shell wrapper to cd.
|
||||
repo=$1
|
||||
if [ -z "$repo" ]; then
|
||||
echo "usage: ghn <repo-name>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
host=$(gum choose "prad.codes" "github.com") || exit 1
|
||||
|
||||
if [ "$host" = prad.codes ]; then
|
||||
owner=$(tea organizations list -o simple | sed 's/^[[:space:]]*//;s/[[:space:]]*$//' | gum choose) || exit 1
|
||||
[ -n "$owner" ] || exit 1
|
||||
|
||||
tea repos create --name "$repo" --owner "$owner" --private >&2 || exit 1
|
||||
|
||||
ghq get "https://prad.codes/$owner/$repo" >&2 || exit 1
|
||||
printf '%s\n' "$(ghq root)/prad.codes/$owner/$repo"
|
||||
else
|
||||
owner=$(gum choose "prdlk" "wltbs") || exit 1
|
||||
[ -n "$owner" ] || exit 1
|
||||
|
||||
gh repo create "$owner/$repo" --private >&2 || exit 1
|
||||
|
||||
ghq get "github.com/$owner/$repo" >&2 || exit 1
|
||||
printf '%s\n' "$(ghq root)/github.com/$owner/$repo"
|
||||
fi
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/bin/bash
|
||||
# Ghostty's `command`: every Ghostty window gets its own tmux session, never
|
||||
# a mirror of another window's. Reclaim the oldest detached session (left by
|
||||
# a closed window, or restored by tmux-continuum after a reboot) so closed
|
||||
# windows don't pile up sessions; otherwise start a new one. pj-palette's
|
||||
# background task sessions (@pj_task, named <session>:<task>) are never
|
||||
# reclaimed, and targets use the session id since names may contain ':'.
|
||||
# The first window after boot starts the server, which is what triggers
|
||||
# continuum's restore.
|
||||
# ponytail: two windows opened in the same instant can claim the same detached
|
||||
# session; take a lock (flock) here if that ever bites.
|
||||
|
||||
# A server started here inherits this PATH. Ghostty launched from the desktop
|
||||
# has no shell init, so add mise's shims for tools tmux plugins shell out to
|
||||
# (bun for tmux-palette, fzf, zoxide).
|
||||
[[ -d $HOME/.local/share/mise/shims ]] && PATH="$HOME/.local/share/mise/shims:$PATH"
|
||||
|
||||
oldest=$(tmux list-sessions -f '#{&&:#{==:#{session_attached},0},#{!:#{@pj_task}}}' \
|
||||
-F '#{session_created} #{session_id}' 2>/dev/null | sort -n | head -1 | cut -d' ' -f2)
|
||||
|
||||
[[ -n $oldest ]] && exec tmux attach-session -t "$oldest"
|
||||
exec tmux new-session
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
# Aggregate all AGENTS.md files in the current git repo via code2prompt.
|
||||
repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
if [ -z "$repo_root" ]; then
|
||||
echo "git-summary: not inside a git repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
template=$HOME/.config/code2prompt/agents-md.hbs
|
||||
if [ ! -f "$template" ]; then
|
||||
echo "git-summary: template not found at $template" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pass through any extra flags (e.g. --output-file, --tokens)
|
||||
exec code2prompt "$repo_root" \
|
||||
--include "AGENTS.md" \
|
||||
--include "**/AGENTS.md" \
|
||||
--template "$template" \
|
||||
"$@"
|
||||
Executable
+69
@@ -0,0 +1,69 @@
|
||||
#!/bin/sh
|
||||
# Open a git repo's web page in the browser — host-agnostic, so it works for
|
||||
# github.com, prad.codes (Gitea), or any other forge whose web layout is
|
||||
# https://<host>/<owner>/<repo> (all of them, in practice).
|
||||
#
|
||||
# Usage: gop [<remote> | <owner>/<repo> | <url>]
|
||||
# no arg → origin, else upstream, else the first remote of the current repo
|
||||
# bare owner/repo → github.com
|
||||
|
||||
target=$1
|
||||
url=
|
||||
|
||||
if [ -n "$target" ]; then
|
||||
case $target in
|
||||
*://* | *@*:* | */*) url=$target ;; # explicit url / scp / owner/repo
|
||||
*)
|
||||
url=$(git remote get-url "$target" 2>/dev/null) || {
|
||||
echo "gop: no remote named '$target'" >&2
|
||||
exit 1
|
||||
}
|
||||
;;
|
||||
esac
|
||||
else
|
||||
for remote in origin upstream $(git remote 2>/dev/null); do
|
||||
url=$(git remote get-url "$remote" 2>/dev/null) && break
|
||||
url=
|
||||
done
|
||||
if [ -z "$url" ]; then
|
||||
echo "gop: not in a git repo, or it has no remotes" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Reduce any remote form to <host>/<owner>/<repo>. The web page is https
|
||||
# unless the remote itself says plain http (a LAN forge, typically).
|
||||
proto=https
|
||||
case $url in
|
||||
*://*)
|
||||
# scheme form: drop scheme and credentials; drop the port for ssh/git
|
||||
# (their port is not the web port), keep it for http/https.
|
||||
scheme=${url%%://*}
|
||||
rest=${url#*://}
|
||||
rest=${rest#*@}
|
||||
case $scheme in
|
||||
ssh | git)
|
||||
host=${rest%%/*}
|
||||
rest="${host%%:*}/${rest#*/}"
|
||||
;;
|
||||
http) proto=http ;;
|
||||
esac
|
||||
url=$rest
|
||||
;;
|
||||
*@*:*)
|
||||
# scp form: git@host:owner/repo
|
||||
url=$(printf '%s\n' "${url#*@}" | sed 's#:#/#')
|
||||
;;
|
||||
esac
|
||||
url=${url%.git}
|
||||
url=${url%/}
|
||||
case $url in
|
||||
*/*/*) ;; # already host/owner/repo
|
||||
*/*) url=github.com/$url ;;
|
||||
*)
|
||||
echo "gop: could not parse a repo out of: ${target:-$url}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
exec ${BROWSER:-xdg-open} "$proto://$url"
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/usr/bin/env bash
|
||||
# Save every kitty daily session (~/.config/kitty/day_session.py) right before
|
||||
# shutdown/reboot.
|
||||
#
|
||||
# kitty-day-save.service runs this under a logind "delay" inhibitor. When a
|
||||
# shutdown or reboot starts, logind broadcasts PrepareForShutdown(true) and
|
||||
# waits (InhibitDelayMaxSec, 5 s by default) for the lock to be released, which
|
||||
# happens when this script exits. Nothing has been killed yet at that point, so
|
||||
# every kitty still has all of its windows. The flush also stops further saves
|
||||
# for a minute, so windows dying during the shutdown can't shrink the saved day.
|
||||
#
|
||||
# Usage: kitty-day-save hold the lock, wait for a shutdown, flush, exit
|
||||
# kitty-day-save --now flush every running kitty immediately
|
||||
set -u
|
||||
|
||||
case "${1-}" in
|
||||
--now)
|
||||
# Every kitty on kitty.conf listens on @kitty-<pid>; ones that don't own
|
||||
# a day session ignore the flush.
|
||||
for s in $(grep -oE '@kitty-[0-9]+$' /proc/net/unix | sort -u); do
|
||||
timeout 3 kitten @ --to "unix:$s" action kitten day_session.py flush >/dev/null 2>&1 &
|
||||
done
|
||||
wait
|
||||
;;
|
||||
--wait)
|
||||
# grep returns on the first match; gdbus is left to the service's cgroup.
|
||||
grep -m1 -q 'PrepareForShutdown (true' < <(
|
||||
gdbus monitor --system --dest org.freedesktop.login1 --object-path /org/freedesktop/login1
|
||||
) && exec "$0" --now
|
||||
;;
|
||||
*)
|
||||
exec systemd-inhibit --what=shutdown --mode=delay --who=kitty \
|
||||
--why='Saving kitty sessions' "$0" --wait
|
||||
;;
|
||||
esac
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
#!/bin/sh
|
||||
# Overlay omp on the current kitty window with a livediff split beside it.
|
||||
#
|
||||
# kitty appends -PID to config-file listen_on sockets, so a hardcoded
|
||||
# --to unix:@kitty-local never matches. kitty @ resolves the real
|
||||
# address from $KITTY_LISTEN_ON on its own.
|
||||
if [ -z "${KITTY_LISTEN_ON:-}" ]; then
|
||||
echo "launch_omp_livediff_overlay: no kitty remote-control socket (KITTY_LISTEN_ON unset)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# omp overlays the current window; capture its window id
|
||||
wid=$(kitty @ launch --type=overlay-main --cwd=current omp) || exit
|
||||
|
||||
# livediff side by side in the same tab; keep focus on omp
|
||||
exec kitty @ launch --match "id:$wid" --location=vsplit --keep-focus --cwd=current livediff
|
||||
Executable
+18
@@ -0,0 +1,18 @@
|
||||
#!/bin/sh
|
||||
# fzf-pick LeetCode solution files and run /leetcode-eli5 on each via omp.
|
||||
root=${1:-.}
|
||||
|
||||
# Match only numbered problem files: 1365.some-slug.js anywhere in the tree
|
||||
files=$(fd --type f '^\d+\..+\.(js|ts)$' "$root" \
|
||||
| fzf --multi \
|
||||
--prompt='eli5> ' \
|
||||
--delimiter=/ \
|
||||
--with-nth=-3.. \
|
||||
--preview 'bat --color=always --line-range=:40 {}' \
|
||||
--preview-window=right:60%)
|
||||
[ -n "$files" ] || exit 1
|
||||
|
||||
printf '%s\n' "$files" | while IFS= read -r f; do
|
||||
echo "── /leetcode-eli5 $f"
|
||||
omp -p "/leetcode-eli5 $f"
|
||||
done
|
||||
Executable
+22
@@ -0,0 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# mandible-pick — pick a tool for mandible (it requires one), then open it.
|
||||
# fzf over the executables installed on PATH (no shell builtins, keywords or
|
||||
# aliases). A query with a space, such as "git remote add", or one that
|
||||
# matches nothing is used as typed (tool + subcommand path).
|
||||
# Used by the tmux-palette "mandible" item (alias: man).
|
||||
|
||||
list() { # portable: no GNU find -printf
|
||||
local d f
|
||||
IFS=: read -ra dirs <<<"$PATH"
|
||||
for d in "${dirs[@]}"; do
|
||||
for f in "$d"/*; do [[ -x $f && ! -d $f ]] && printf '%s\n' "${f##*/}"; done
|
||||
done
|
||||
}
|
||||
# enter: a query with a space ("git remote add") is a tool + subcommand path,
|
||||
# taken as typed; otherwise the highlighted command, or the query if none.
|
||||
q=$(list | sort -u | fzf --prompt 'mandible> ' --height 100% --reverse \
|
||||
--with-shell 'bash -c' \
|
||||
--bind 'enter:transform:[[ {q} == *" "* ]] && echo print-query || echo accept-or-print-query') || exit 0
|
||||
[ -n "$q" ] || exit 0
|
||||
# shellcheck disable=SC2086 # split "git remote" into tool + subcommands
|
||||
exec mandible $q
|
||||
Executable
+17
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
# Fetch a URL as markdown, copy to clipboard, preview with mdfried.
|
||||
url=$1
|
||||
if [ -z "$url" ]; then
|
||||
echo "usage: mdcp <url>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if command -v wl-copy >/dev/null 2>&1; then
|
||||
copy=wl-copy paste=wl-paste
|
||||
else
|
||||
echo "mdcp: no clipboard tool (wl-copy) found" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl.md "$url" | "$copy"
|
||||
"$paste" | mdfried
|
||||
Executable
+6
@@ -0,0 +1,6 @@
|
||||
#!/bin/sh
|
||||
# Interactive Markdown selector and previewer.
|
||||
file=$(fd --type f --extension md --max-depth 3 \
|
||||
| fzf --preview 'mdfried {}' --preview-window=right:60%:wrap) || exit 0
|
||||
[ -n "$file" ] || exit 0
|
||||
exec mdfried "$file"
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
# Sort stdin lines newest-first by mtime — the base order of the television
|
||||
# cables (tv then layers match quality and frecency on top).
|
||||
#
|
||||
# The path is each line's last tab-separated field (the whole line when it
|
||||
# has no tab). With --repo, <path>/.git/index is stat'ed first, so a repo
|
||||
# ranks by its last git activity rather than its directory mtime. Lines whose
|
||||
# path does not exist are dropped; ties keep their input order.
|
||||
#
|
||||
# Usage: <lines> | mtime-sort [--repo]
|
||||
exec perl -e '
|
||||
my $repo = @ARGV && $ARGV[0] eq "--repo";
|
||||
my ($i, @rows) = (0);
|
||||
while (my $line = <STDIN>) {
|
||||
chomp $line;
|
||||
my $path = (split /\t/, $line)[-1];
|
||||
next unless defined $path && length $path;
|
||||
my $mtime;
|
||||
$mtime = (stat "$path/.git/index")[9] if $repo;
|
||||
$mtime = (stat $path)[9] unless defined $mtime;
|
||||
push @rows, [$mtime, $i++, $line] if defined $mtime;
|
||||
}
|
||||
print "$_->[2]\n" for sort { $b->[0] <=> $a->[0] || $a->[1] <=> $b->[1] } @rows;
|
||||
' -- "$@"
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Cycle through the windows of an app, or launch it if none are open
|
||||
# omarchy:args=<app|browser|terminal|ai|webapp|webapp-NAME|kitty-CLASS>
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
APP="${1:-}"
|
||||
|
||||
if [[ -z $APP ]]; then
|
||||
echo "Usage: omarchy-cycle <app|browser|terminal|ai|webapp|webapp-NAME|kitty-CLASS>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Per-class launch commands for kitty-* (class -> command run via `kitty -e`).
|
||||
# Unmapped classes just open a plain kitty with that class.
|
||||
declare -A KITTY_CMDS=(
|
||||
# [kitty-lazygit]="lazygit"
|
||||
# [kitty-btop]="btop"
|
||||
)
|
||||
|
||||
# --- Hyprland helpers --------------------------------------------------------
|
||||
|
||||
# Print the addresses of all windows whose class or initialClass matches the
|
||||
# regex in $1. An optional regex in $2 excludes matches again — used to keep
|
||||
# scratch windows out of a cycle. Windows are sorted by workspace, then by
|
||||
# address, so the cycle order stays the same between calls.
|
||||
addresses_matching() {
|
||||
hyprctl clients -j | jq -r --arg re "$1" --arg not "${2:-}" '
|
||||
[ .[]
|
||||
| select(((.class // "") | test($re; "i")) or ((.initialClass // "") | test($re; "i")))
|
||||
| select($not == "" or (
|
||||
(((.class // "") | test($not; "i")) or ((.initialClass // "") | test($not; "i"))) | not
|
||||
))
|
||||
]
|
||||
| sort_by(.workspace.id, .address)
|
||||
| .[].address'
|
||||
}
|
||||
|
||||
# Same, but drops site-specific app (SSA) windows, so real browsers stay apart
|
||||
# from web apps that share a class prefix.
|
||||
addresses_matching_no_ssa() {
|
||||
hyprctl clients -j | jq -r --arg re "$1" '
|
||||
[ .[]
|
||||
| select(((.class // "") | test($re; "i")) or ((.initialClass // "") | test($re; "i")))
|
||||
| select(((.class // "") | test("^(chrome-|webapp-)"; "i")) | not)
|
||||
]
|
||||
| sort_by(.workspace.id, .address)
|
||||
| .[].address'
|
||||
}
|
||||
|
||||
focused_address() {
|
||||
hyprctl activewindow -j 2>/dev/null | jq -r '.address // empty'
|
||||
}
|
||||
|
||||
focus_window() {
|
||||
hyprctl dispatch "hl.dsp.focus({ window = \"address:$1\" })" >/dev/null 2>&1 ||
|
||||
hyprctl dispatch focuswindow "address:$1" >/dev/null
|
||||
}
|
||||
|
||||
spawn() {
|
||||
exec setsid uwsm-app -- $1
|
||||
}
|
||||
|
||||
# --- Find the windows --------------------------------------------------------
|
||||
|
||||
declare -a INSTANCES
|
||||
|
||||
if [[ $APP == "webapp" ]]; then
|
||||
# Cycle all web apps: Firefox SSAs (WebApp-*) and Chromium/Brave SSAs (chrome-*).
|
||||
# Real browsers are excluded, because their class has no SSA prefix.
|
||||
INSTANCES=($(addresses_matching '^(webapp-|chrome-)'))
|
||||
FALLBACK_APP="webapp-youtube"
|
||||
|
||||
elif [[ $APP == webapp-* ]]; then
|
||||
# Extract the web app name (e.g. "webapp-claude" -> "claude").
|
||||
WEBAPP_NAME="${APP#webapp-}"
|
||||
# Firefox SSAs use the class WebApp-<Name>. Chromium SSAs use
|
||||
# chrome-<host>__-Default, so the name is matched anywhere in the class.
|
||||
INSTANCES=($(addresses_matching "webapp-${WEBAPP_NAME}|^chrome-.*${WEBAPP_NAME}"))
|
||||
|
||||
if ((${#INSTANCES[@]} == 0)); then
|
||||
# No window is open. Launch from the desktop entry.
|
||||
DESKTOP_FILE=$(find ~/.local/share/applications -iname "WebApp-${WEBAPP_NAME}*.desktop" -print -quit 2>/dev/null)
|
||||
[[ -n $DESKTOP_FILE ]] ||
|
||||
DESKTOP_FILE=$(find ~/.local/share/applications -iname "*${WEBAPP_NAME}*.desktop" -print -quit 2>/dev/null)
|
||||
|
||||
if [[ -n $DESKTOP_FILE ]]; then
|
||||
exec setsid gtk-launch "$(basename "$DESKTOP_FILE")"
|
||||
else
|
||||
echo "No desktop file found for web app: ${WEBAPP_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
elif [[ $APP == "kitty" ]]; then
|
||||
# Cycle all kitty windows, whatever the class (kitty, kitty-nvim, ...).
|
||||
INSTANCES=($(addresses_matching '^kitty'))
|
||||
DEFAULT_SPAWN="kitty"
|
||||
|
||||
elif [[ $APP == kitty-* ]]; then
|
||||
# One kitty class, e.g. kitty-nvim -> class "kitty-nvim".
|
||||
INSTANCES=($(addresses_matching "^${APP}$"))
|
||||
|
||||
if ((${#INSTANCES[@]} == 0)); then
|
||||
cmd="${KITTY_CMDS[$APP]:-}"
|
||||
if [[ -n $cmd ]]; then
|
||||
spawn "kitty --class $APP -e $cmd"
|
||||
else
|
||||
spawn "kitty --class $APP"
|
||||
fi
|
||||
fi
|
||||
|
||||
elif [[ $APP == "ghostty" ]]; then
|
||||
# Cycle Ghostty terminals (class com.mitchellh.ghostty).
|
||||
INSTANCES=($(addresses_matching 'ghostty'))
|
||||
DEFAULT_SPAWN="ghostty"
|
||||
|
||||
elif [[ $APP == "browser" ]]; then
|
||||
# Cycle all browsers. Web apps are left out.
|
||||
INSTANCES=($(addresses_matching_no_ssa '^(zen|brave|chromium|google-chrome|firefox|librewolf|vivaldi|microsoft-edge|helium)'))
|
||||
DEFAULT_LAUNCHER="omarchy-launch-browser"
|
||||
|
||||
elif [[ $APP == "brave" ]]; then
|
||||
# Cycle the Brave Origin browser and all of its web apps. Both the
|
||||
# real browser (class brave-origin) and the SSAs (class brave-<host>)
|
||||
# share the brave- prefix, so one regex catches them all.
|
||||
INSTANCES=($(addresses_matching '^brave'))
|
||||
DEFAULT_LAUNCHER="omarchy-launch-browser"
|
||||
|
||||
elif [[ $APP == "terminal" ]]; then
|
||||
# Cycle all terminals. Tern (so.stencil.tern) is the SUPER + A scratch
|
||||
# terminal and the pattern below already leaves it out.
|
||||
INSTANCES=($(addresses_matching '(kitty|alacritty|ghostty|foot|wezterm)'))
|
||||
DEFAULT_LAUNCHER="omarchy-launch-terminal"
|
||||
|
||||
elif [[ $APP == "ai" ]]; then
|
||||
# Cycle the AI desktop apps (Claude/Hermes).
|
||||
INSTANCES=($(addresses_matching '(claude|hermes)'))
|
||||
DEFAULT_SPAWN="hermes desktop"
|
||||
|
||||
else
|
||||
# Any other app, matched on the full class.
|
||||
INSTANCES=($(addresses_matching "^${APP}$"))
|
||||
fi
|
||||
|
||||
# --- Launch if nothing is open -----------------------------------------------
|
||||
|
||||
if ((${#INSTANCES[@]} == 0)); then
|
||||
if [[ -n ${DEFAULT_LAUNCHER:-} ]]; then
|
||||
exec setsid "$DEFAULT_LAUNCHER"
|
||||
fi
|
||||
|
||||
if [[ -n ${DEFAULT_SPAWN:-} ]]; then
|
||||
spawn "$DEFAULT_SPAWN"
|
||||
fi
|
||||
|
||||
if [[ -n ${FALLBACK_APP:-} ]]; then
|
||||
exec "$0" "$FALLBACK_APP"
|
||||
fi
|
||||
|
||||
if [[ $APP == "webapp" ]]; then
|
||||
echo "No ${APP} windows running"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
spawn "$APP"
|
||||
fi
|
||||
|
||||
# --- Focus, or cycle to the next window --------------------------------------
|
||||
|
||||
if ((${#INSTANCES[@]} == 1)); then
|
||||
focus_window "${INSTANCES[0]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FOCUSED=$(focused_address)
|
||||
|
||||
# If the focused window is one of ours, go to the next one. If it is not,
|
||||
# go to the first one.
|
||||
next_index=0
|
||||
for i in "${!INSTANCES[@]}"; do
|
||||
if [[ -n $FOCUSED && ${INSTANCES[$i]} == "$FOCUSED" ]]; then
|
||||
next_index=$(((i + 1) % ${#INSTANCES[@]}))
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
focus_window "${INSTANCES[$next_index]}"
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Cycle Brave Origin windows and all its web apps; launch brave-origin if none are open.
|
||||
|
||||
exec "$(dirname "$(readlink -f "$0")")/omarchy-cycle" brave
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Cycle through all terminals (ghostty/kitty/alacritty/foot/wezterm); launch the default if none are open.
|
||||
|
||||
exec "$(dirname "$(readlink -f "$0")")/omarchy-cycle" terminal
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Cycle the focused window through preset sizes
|
||||
# omarchy:args=[next|prev]
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
DIRECTION="${1:-next}"
|
||||
|
||||
# Preset widths, as a fraction of the screen. Edit to taste.
|
||||
# These only apply to dwindle and to floating windows. On a scrolling
|
||||
# workspace the layout cycles its own list instead. Set that list with:
|
||||
# hl.config({ scrolling = { explicit_column_widths = "0.333, 0.5, 0.667, 1.0" } })
|
||||
FRACTIONS=(0.333 0.5 0.667)
|
||||
|
||||
# Height of a floating window, as a fraction of the screen.
|
||||
FLOATING_HEIGHT=0.8
|
||||
|
||||
STATE_DIR="${XDG_STATE_HOME:-$HOME/.local/state}/omarchy/window-sizes"
|
||||
|
||||
# Try the Lua dispatcher first, then fall back to the legacy name.
|
||||
hypr_dispatch() {
|
||||
local lua="$1"
|
||||
shift
|
||||
|
||||
hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null
|
||||
}
|
||||
|
||||
calc() {
|
||||
awk "BEGIN { printf \"%s\", $1 }"
|
||||
}
|
||||
|
||||
ACTIVE=$(hyprctl activewindow -j 2>/dev/null)
|
||||
[[ -n $ACTIVE && $ACTIVE != "{}" ]] || exit 0
|
||||
|
||||
ADDRESS=$(jq -r '.address // empty' <<<"$ACTIVE")
|
||||
FLOATING=$(jq -r '.floating // false' <<<"$ACTIVE")
|
||||
WORKSPACE=$(jq -r '.workspace.id // empty' <<<"$ACTIVE")
|
||||
LAYOUT=$(hyprctl activeworkspace -j 2>/dev/null | jq -r '.tiledLayout // empty')
|
||||
|
||||
# --- Scrolling: the layout keeps its own preset list ------------------------
|
||||
|
||||
if [[ $FLOATING != "true" && $LAYOUT == "scrolling" ]]; then
|
||||
if [[ $DIRECTION == "prev" ]]; then
|
||||
MSG="colresize -conf"
|
||||
else
|
||||
MSG="colresize +conf"
|
||||
fi
|
||||
|
||||
hypr_dispatch "hl.dsp.layout(\"$MSG\")" layoutmsg "$MSG"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Step to the next preset ------------------------------------------------
|
||||
|
||||
# Floating windows keep an index of their own. Tiled windows share one index
|
||||
# per workspace, because the split is a property of the workspace.
|
||||
if [[ $FLOATING == "true" ]]; then
|
||||
STATE_KEY="window-${ADDRESS}"
|
||||
else
|
||||
STATE_KEY="workspace-${WORKSPACE}"
|
||||
fi
|
||||
|
||||
STATE_FILE="$STATE_DIR/${STATE_KEY}.index"
|
||||
COUNT=${#FRACTIONS[@]}
|
||||
|
||||
INDEX=0
|
||||
if [[ -f $STATE_FILE ]]; then
|
||||
INDEX=$(<"$STATE_FILE")
|
||||
[[ $INDEX =~ ^[0-9]+$ ]] || INDEX=0
|
||||
fi
|
||||
|
||||
if [[ $DIRECTION == "prev" ]]; then
|
||||
INDEX=$(((INDEX - 1 + COUNT) % COUNT))
|
||||
else
|
||||
INDEX=$(((INDEX + 1) % COUNT))
|
||||
fi
|
||||
|
||||
mkdir -p "$STATE_DIR"
|
||||
printf '%s\n' "$INDEX" >"$STATE_FILE"
|
||||
|
||||
FRACTION=${FRACTIONS[$INDEX]}
|
||||
|
||||
# --- Floating: set an absolute size and center ------------------------------
|
||||
|
||||
if [[ $FLOATING == "true" ]]; then
|
||||
read -r MON_W MON_H < <(hyprctl monitors -j |
|
||||
jq -r '.[] | select(.focused) | "\(((.width / .scale) | floor)) \(((.height / .scale) | floor))"')
|
||||
|
||||
[[ -n ${MON_W:-} && -n ${MON_H:-} ]] || exit 1
|
||||
|
||||
WIDTH=$(calc "int($MON_W * $FRACTION)")
|
||||
HEIGHT=$(calc "int($MON_H * $FLOATING_HEIGHT)")
|
||||
|
||||
hypr_dispatch "hl.dsp.window.resize({ x = $WIDTH, y = $HEIGHT })" resizeactive exact "$WIDTH" "$HEIGHT"
|
||||
hypr_dispatch "hl.dsp.window.center()" centerwindow
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# --- Dwindle: set the split ratio -------------------------------------------
|
||||
|
||||
# splitratio 1.0 is an even split, so a fraction of 0.5 maps to 1.0.
|
||||
# With the default split_bias of 0, the ratio goes to the left or top window.
|
||||
# So when the focused window is the right or bottom one, the fraction is
|
||||
# mirrored. Which side the window sits on is read from its position.
|
||||
read -r WIN_X WIN_Y WIN_W WIN_H < <(jq -r '"\(.at[0]) \(.at[1]) \(.size[0]) \(.size[1])"' <<<"$ACTIVE")
|
||||
read -r MON_X MON_Y MON_W MON_H < <(hyprctl monitors -j |
|
||||
jq -r '.[] | select(.focused) | "\(.x) \(.y) \(((.width / .scale) | floor)) \(((.height / .scale) | floor))"')
|
||||
|
||||
MIRROR=false
|
||||
if ((WIN_W * 100 > MON_W * 90)); then
|
||||
# The window spans the screen, so the split is top and bottom.
|
||||
((WIN_Y + WIN_H / 2 > MON_Y + MON_H / 2)) && MIRROR=true
|
||||
else
|
||||
((WIN_X + WIN_W / 2 > MON_X + MON_W / 2)) && MIRROR=true
|
||||
fi
|
||||
|
||||
if [[ $MIRROR == "true" ]]; then
|
||||
RATIO=$(calc "2 * (1 - $FRACTION)")
|
||||
else
|
||||
RATIO=$(calc "2 * $FRACTION")
|
||||
fi
|
||||
|
||||
# Hyprland accepts 0.1 to 1.9.
|
||||
RATIO=$(calc "($RATIO < 0.1 ? 0.1 : ($RATIO > 1.9 ? 1.9 : $RATIO))")
|
||||
|
||||
hypr_dispatch "hl.dsp.layout(\"splitratio $RATIO exact\")" layoutmsg "splitratio $RATIO exact"
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Launch the default browser; a cold Brave Origin start reopens the last session without the "Restore pages?" prompt.
|
||||
# omarchy:args=[url]
|
||||
|
||||
# Shadows /usr/bin/omarchy-launch-browser (~/.local/bin is earlier on PATH)
|
||||
# and hands off to it. A reboot or power-off kills Brave uncleanly, so the
|
||||
# next start counts as post-crash: Chromium then skips session restore and
|
||||
# shows the "Restore pages?" bubble instead. --hide-crash-restore-bubble
|
||||
# clears that post-crash state, and --restore-last-session reopens every
|
||||
# window. Added only on a cold, non-private start: a running browser ignores
|
||||
# both, and Omarchy's launcher would read the first flag as the URL to focus.
|
||||
if [[ " $* " != *" --private "* ]] &&
|
||||
[[ $(env -u BROWSER xdg-settings get default-web-browser) == brave-origin.desktop ]] &&
|
||||
! pgrep -f '^/opt/brave-origin-bin/brave( |$)' >/dev/null; then
|
||||
set -- --restore-last-session --hide-crash-restore-bubble "$@"
|
||||
fi
|
||||
|
||||
exec /usr/bin/omarchy-launch-browser "$@"
|
||||
Executable
+70
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Toggle a window between picture-in-picture (floating, pinned, bottom-left corner of the focused monitor) and tiled.
|
||||
# omarchy:args=[toggle|on] [address:0x...]
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# Usage: omarchy-pip [toggle|on] [address:0x...]
|
||||
#
|
||||
# "on" always fits the window: float, pin, resize to FRACTION of the focused
|
||||
# monitor on both axes and park it in the bottom-left corner, inset by GAP.
|
||||
# "toggle" (the default) does that unless the window is already floating and
|
||||
# pinned, in which case it unpins and tiles it again. The window defaults to
|
||||
# the active one. omarchy-summon calls "on" after carrying its window to the
|
||||
# focused monitor; the YouTube window rule in hypr/hyprland.lua mirrors the
|
||||
# same fraction and inset for the first open, so keep the three in sync.
|
||||
MODE=${1:-toggle}
|
||||
WINDOW=${2:-}
|
||||
case $MODE in
|
||||
toggle | on) ;;
|
||||
*)
|
||||
echo "usage: ${0##*/} [toggle|on] [address:0x...]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
FRACTION=0.3
|
||||
# Matches general:gaps_out so the window lines up with tiled neighbours and
|
||||
# its border stays on screen.
|
||||
GAP=10
|
||||
|
||||
# Lua dispatcher first, classic name as the pre-lua fallback (same shape as
|
||||
# the other omarchy-* scripts here).
|
||||
hypr_dispatch() {
|
||||
local lua="$1"
|
||||
shift
|
||||
hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null
|
||||
}
|
||||
|
||||
if [[ -z $WINDOW ]]; then
|
||||
ACTIVE=$(hyprctl activewindow -j 2>/dev/null)
|
||||
[[ -n $ACTIVE && $ACTIVE != "{}" ]] || exit 0
|
||||
WINDOW="address:$(jq -r '.address' <<<"$ACTIVE")"
|
||||
else
|
||||
ACTIVE=$(hyprctl clients -j | jq -c --arg a "${WINDOW#address:}" 'first(.[] | select(.address == $a)) // empty')
|
||||
[[ -n $ACTIVE ]] || exit 1
|
||||
fi
|
||||
|
||||
# The wiki documents action = "set", but on Hyprland 0.56 "set" toggles;
|
||||
# "on" / "off" are what the dispatcher actually honours.
|
||||
if [[ $MODE == toggle ]] && [[ $(jq -r '.floating and .pinned' <<<"$ACTIVE") == true ]]; then
|
||||
hypr_dispatch "hl.dsp.window.pin({ action = \"off\", window = \"$WINDOW\" })" pin "$WINDOW"
|
||||
hypr_dispatch "hl.dsp.window.float({ action = \"off\", window = \"$WINDOW\" })" settiled "$WINDOW"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Focused monitor: origin and logical size.
|
||||
read -r MON_X MON_Y MON_W MON_H < <(hyprctl monitors -j |
|
||||
jq -r '.[] | select(.focused) | "\(.x) \(.y) \(((.width / .scale) | floor)) \(((.height / .scale) | floor))"')
|
||||
[[ -n ${MON_H:-} ]] || exit 1
|
||||
|
||||
WIDTH=$(awk "BEGIN { printf \"%d\", $MON_W * $FRACTION }")
|
||||
HEIGHT=$(awk "BEGIN { printf \"%d\", $MON_H * $FRACTION }")
|
||||
X=$((MON_X + GAP))
|
||||
Y=$((MON_Y + MON_H - HEIGHT - GAP))
|
||||
|
||||
hypr_dispatch "hl.dsp.window.float({ action = \"on\", window = \"$WINDOW\" })" setfloating "$WINDOW"
|
||||
hypr_dispatch "hl.dsp.window.pin({ action = \"on\", window = \"$WINDOW\" })" pin "$WINDOW"
|
||||
hypr_dispatch "hl.dsp.window.resize({ x = $WIDTH, y = $HEIGHT, window = \"$WINDOW\" })" resizewindowpixel "exact $WIDTH $HEIGHT,$WINDOW"
|
||||
hypr_dispatch "hl.dsp.window.move({ x = $X, y = $Y, window = \"$WINDOW\" })" movewindowpixel "exact $X $Y,$WINDOW"
|
||||
Executable
+106
@@ -0,0 +1,106 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Move the focused window into a neighboring group or swap with the neighbor; floating windows go to a screen corner
|
||||
# omarchy:args=<l|r|u|d>
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
DIR="${1:-}"
|
||||
|
||||
case "$DIR" in
|
||||
l | r | u | d) ;;
|
||||
*)
|
||||
echo "Usage: omarchy-smart-move <l|r|u|d>"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
hypr_dispatch() {
|
||||
local lua="$1"
|
||||
shift
|
||||
|
||||
hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null
|
||||
}
|
||||
|
||||
ACTIVE=$(hyprctl activewindow -j 2>/dev/null)
|
||||
[[ -n $ACTIVE && $ACTIVE != "{}" ]] || exit 0
|
||||
|
||||
ADDRESS=$(jq -r '.address // empty' <<<"$ACTIVE")
|
||||
WORKSPACE=$(jq -r '.workspace.id // empty' <<<"$ACTIVE")
|
||||
GROUPED=$(jq -r '(.grouped // []) | length' <<<"$ACTIVE")
|
||||
|
||||
[[ -n $ADDRESS && -n $WORKSPACE ]] || exit 0
|
||||
|
||||
# A floating window has no neighbours to swap with, so the four keys park it
|
||||
# in a corner of its monitor instead, size unchanged, inside the reserved
|
||||
# area (the bar) and inset by GAP (matches general:gaps_out, same as
|
||||
# omarchy-pip). h/j/k/l read clockwise from the bottom left: l bottom-left,
|
||||
# d top-left, u top-right, r bottom-right.
|
||||
if [[ $(jq -r '.floating' <<<"$ACTIVE") == true ]]; then
|
||||
GAP=10
|
||||
read -r WIN_W WIN_H MON_ID < <(jq -r '"\(.size[0]) \(.size[1]) \(.monitor)"' <<<"$ACTIVE")
|
||||
read -r MON_X MON_Y MON_W MON_H RES_L RES_T RES_R RES_B < <(hyprctl monitors -j | jq -r --argjson id "$MON_ID" \
|
||||
'.[] | select(.id == $id) | "\(.x) \(.y) \(((.width / .scale) | floor)) \(((.height / .scale) | floor)) \(.reserved | join(" "))"')
|
||||
[[ -n ${RES_B:-} ]] || exit 1
|
||||
|
||||
LEFT=$((MON_X + RES_L + GAP))
|
||||
RIGHT=$((MON_X + MON_W - RES_R - WIN_W - GAP))
|
||||
TOP=$((MON_Y + RES_T + GAP))
|
||||
BOTTOM=$((MON_Y + MON_H - RES_B - WIN_H - GAP))
|
||||
case $DIR in
|
||||
l) X=$LEFT Y=$BOTTOM ;;
|
||||
d) X=$LEFT Y=$TOP ;;
|
||||
u) X=$RIGHT Y=$TOP ;;
|
||||
r) X=$RIGHT Y=$BOTTOM ;;
|
||||
esac
|
||||
|
||||
hypr_dispatch "hl.dsp.window.move({ x = $X, y = $Y })" movewindowpixel "exact $X $Y,address:$ADDRESS"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Find the nearest window in the given direction. A window counts as a
|
||||
# neighbor only when it overlaps the focused window on the other axis, so a
|
||||
# window in a far corner is not picked.
|
||||
NEIGHBOR_GROUPED=$(hyprctl clients -j | jq -r \
|
||||
--arg address "$ADDRESS" \
|
||||
--arg dir "$DIR" \
|
||||
--argjson workspace "$WORKSPACE" '
|
||||
( [ .[] | select(.address == $address) ] | first ) as $self
|
||||
| if $self == null then "none" else
|
||||
( $self.at[0] + $self.size[0] / 2 ) as $sx
|
||||
| ( $self.at[1] + $self.size[1] / 2 ) as $sy
|
||||
| [ .[]
|
||||
| select(.address != $address)
|
||||
| select(.workspace.id == $workspace)
|
||||
| select(.floating == false)
|
||||
| select(.hidden == false)
|
||||
| . + {
|
||||
cx: (.at[0] + .size[0] / 2),
|
||||
cy: (.at[1] + .size[1] / 2)
|
||||
}
|
||||
| select(
|
||||
if $dir == "l" then .cx < $sx and (.at[1] < ($self.at[1] + $self.size[1])) and (($self.at[1]) < (.at[1] + .size[1]))
|
||||
elif $dir == "r" then .cx > $sx and (.at[1] < ($self.at[1] + $self.size[1])) and (($self.at[1]) < (.at[1] + .size[1]))
|
||||
elif $dir == "u" then .cy < $sy and (.at[0] < ($self.at[0] + $self.size[0])) and (($self.at[0]) < (.at[0] + .size[0]))
|
||||
else .cy > $sy and (.at[0] < ($self.at[0] + $self.size[0])) and (($self.at[0]) < (.at[0] + .size[0]))
|
||||
end
|
||||
)
|
||||
]
|
||||
| sort_by(
|
||||
if $dir == "l" or $dir == "r" then (.cx - $sx | fabs) else (.cy - $sy | fabs) end
|
||||
)
|
||||
| first
|
||||
| if . == null then "none"
|
||||
elif ((.grouped // []) | length) > 0 then "group"
|
||||
else "single"
|
||||
end
|
||||
end')
|
||||
|
||||
# A group on either side means the move is group aware: Hyprland puts the
|
||||
# window into the neighboring group, or takes it out of its own group at the
|
||||
# edge. With no group in play, swap places with the neighbor instead.
|
||||
if [[ $NEIGHBOR_GROUPED == "group" || $GROUPED -gt 1 ]]; then
|
||||
hypr_dispatch "hl.dsp.window.move({ direction = \"$DIR\", group_aware = true })" movewindoworgroup "$DIR"
|
||||
else
|
||||
hypr_dispatch "hl.dsp.window.swap({ direction = \"$DIR\" })" swapwindow "$DIR"
|
||||
fi
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Launch a floating pinned app (youtube) or bring its window to the focused monitor's bottom-left corner.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# Usage: omarchy-summon [youtube]
|
||||
#
|
||||
# Each profile is one app kept as a picture-in-picture window: the window
|
||||
# rule in hypr/hyprland.lua floats, pins, sizes and parks it when it first
|
||||
# opens, so a plain launch is enough when no window exists. When one does,
|
||||
# this script carries it to the focused monitor and hands it to omarchy-pip
|
||||
# to fit it there again: the two panels differ in logical size, and Hyprland
|
||||
# keeps a floating window's pixel size and offset across a monitor move.
|
||||
#
|
||||
# PATTERN is an ERE over the window class. A web app's picture-in-picture
|
||||
# overlay shares the class, so windows titled Picture-in-Picture are skipped
|
||||
# (Omarchy's own pip rule owns those).
|
||||
case ${1:-youtube} in
|
||||
youtube)
|
||||
# Chromium derives the class from the app URL, and the same web app has
|
||||
# shown up under more than one host spelling (youtube.com vs
|
||||
# www.youtube.com) once a video is playing, so match the family.
|
||||
PATTERN='^brave-(www\.)?youtube\.com__.*$'
|
||||
LAUNCH="gtk-launch YouTube"
|
||||
;;
|
||||
*)
|
||||
echo "usage: ${0##*/} [youtube]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
# Lua dispatcher first, classic name as the pre-lua fallback (same shape as
|
||||
# the other omarchy-* scripts here).
|
||||
hypr_dispatch() {
|
||||
local lua="$1"
|
||||
shift
|
||||
hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null
|
||||
}
|
||||
|
||||
ADDRESS=$(hyprctl clients -j | jq -r --arg re "$PATTERN" '
|
||||
first(.[] | select((.class | test($re)) and ((.title | test("Picture.?in.?[Pp]icture")) | not)))
|
||||
| .address // empty')
|
||||
|
||||
if [[ -z $ADDRESS ]]; then
|
||||
exec setsid uwsm-app -- $LAUNCH >/dev/null 2>&1
|
||||
fi
|
||||
|
||||
WINDOW="address:$ADDRESS"
|
||||
MON_NAME=$(hyprctl monitors -j | jq -r '.[] | select(.focused) | .name')
|
||||
[[ -n $MON_NAME ]] || exit 1
|
||||
|
||||
hypr_dispatch "hl.dsp.window.move({ monitor = \"$MON_NAME\", window = \"$WINDOW\" })" movewindow "mon:$MON_NAME,$WINDOW"
|
||||
omarchy-pip on "$WINDOW"
|
||||
hypr_dispatch "hl.dsp.focus({ window = \"$WINDOW\" })" focuswindow "$WINDOW"
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Toggle the workspace layout, and set every column to one width when it becomes scrolling
|
||||
# omarchy:args=[width]
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# Column width, as a fraction of the screen, applied to every column on the
|
||||
# workspace when it turns to scrolling.
|
||||
WIDTH="${1:-0.75}"
|
||||
|
||||
hypr_dispatch() {
|
||||
local lua="$1"
|
||||
shift
|
||||
|
||||
hyprctl dispatch "$lua" >/dev/null 2>&1 || hyprctl dispatch "$@" >/dev/null
|
||||
}
|
||||
|
||||
current_layout() {
|
||||
hyprctl activeworkspace -j 2>/dev/null | jq -r '.tiledLayout // empty'
|
||||
}
|
||||
|
||||
omarchy-hyprland-workspace-layout-toggle || exit 1
|
||||
|
||||
# The toggle writes a workspace rule, so the new layout can take a moment to
|
||||
# show up. Read until it does, then act only on the scrolling case: dwindle
|
||||
# re-tiles on its own and needs no widths.
|
||||
for _ in {1..6}; do
|
||||
case "$(current_layout)" in
|
||||
scrolling)
|
||||
MSG="colresize all $WIDTH"
|
||||
hypr_dispatch "hl.dsp.layout(\"$MSG\")" layoutmsg "$MSG"
|
||||
exit 0
|
||||
;;
|
||||
dwindle)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
sleep 0.05
|
||||
done
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
#!/bin/bash
|
||||
|
||||
# omarchy:summary=Toggle a scratch workspace (scratchpad, claude, fastmail, beeper, home, lmstudio), starting its resident apps when any is missing.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
# Usage: omarchy-toggle-scratch [scratchpad|claude|fastmail|beeper|home|lmstudio]
|
||||
#
|
||||
# Each profile is a special workspace, the monitor it opens on, and its
|
||||
# resident apps (class pattern -> launch command; the pattern is an anchored
|
||||
# ERE, so a plain class name matches itself). hypr/hyprland.lua pins every
|
||||
# class to its workspace, so only the launch and the reveal are left here. A
|
||||
# resident is launched only when no window on the workspace matches it, which
|
||||
# is what keeps a launcher that always opens a fresh window (the Chromium
|
||||
# web apps) from piling up duplicates. The scrolling layout gives each
|
||||
# resident its own column, in the order they appear.
|
||||
#
|
||||
# HOME_MONITOR lists connector names or EDID descriptions, separated by |;
|
||||
# the first one connected wins (descriptions, as in the workspace rules,
|
||||
# survive a different DP connector after re-docking). When it is empty or
|
||||
# none is connected, the workspace opens on the focused monitor. EXTERNAL is
|
||||
# the main external display: the Studio Display, else the Samsung ultrawide
|
||||
# (same order as hypr/monitors.lua).
|
||||
EXTERNAL="Apple Computer Inc StudioDisplay 0x984262A7|Samsung Electric Company S34CG50 HNTY501103"
|
||||
HOME_MONITOR=""
|
||||
case ${1:-scratchpad} in
|
||||
scratchpad)
|
||||
NAME="scratchpad"
|
||||
HOME_MONITOR=$EXTERNAL
|
||||
MEMBERS=("so.stencil.tern|tern")
|
||||
;;
|
||||
claude)
|
||||
NAME="claude"
|
||||
HOME_MONITOR=$EXTERNAL
|
||||
MEMBERS=("com.anthropic.Claude|claude-desktop")
|
||||
;;
|
||||
fastmail)
|
||||
NAME="fastmail"
|
||||
HOME_MONITOR="eDP-1"
|
||||
MEMBERS=("com.fastmail.Fastmail|gtk-launch com.fastmail.Fastmail")
|
||||
;;
|
||||
beeper)
|
||||
NAME="beeper"
|
||||
HOME_MONITOR="eDP-1"
|
||||
MEMBERS=("Beeper|gtk-launch beeper")
|
||||
;;
|
||||
home)
|
||||
NAME="home"
|
||||
MEMBERS=("brave-home\.google\.com__.*|gtk-launch 'Google Home'")
|
||||
;;
|
||||
lmstudio)
|
||||
NAME="lmstudio"
|
||||
MEMBERS=("ai\.elementlabs\.lmstudio|gtk-launch ai.elementlabs.lmstudio")
|
||||
;;
|
||||
*)
|
||||
echo "usage: ${0##*/} [scratchpad|claude|fastmail|beeper|home|lmstudio]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
WORKSPACE="special:$NAME"
|
||||
|
||||
# Classes of the windows living on the scratchpad, shown or hidden.
|
||||
scratch_classes() {
|
||||
hyprctl clients -j | jq -r --arg ws "$WORKSPACE" '.[] | select(.workspace.name == $ws) | .class'
|
||||
}
|
||||
|
||||
scratch_windows() {
|
||||
scratch_classes | wc -l
|
||||
}
|
||||
|
||||
# Name of the monitor currently displaying the scratchpad, if any.
|
||||
scratch_monitor() {
|
||||
hyprctl monitors -j | jq -r --arg ws "$WORKSPACE" '.[] | select(.specialWorkspace.name == $ws) | .name'
|
||||
}
|
||||
|
||||
scratch_visible() {
|
||||
[[ -n $(scratch_monitor) ]]
|
||||
}
|
||||
|
||||
# Hyprland's lua config takes lua expressions on `hyprctl dispatch`; the
|
||||
# classic dispatcher is the pre-lua fallback (same shape as omarchy-cycle).
|
||||
focus_monitor() {
|
||||
hyprctl dispatch "hl.dsp.focus({ monitor = \"$1\" })" >/dev/null 2>&1 ||
|
||||
hyprctl dispatch focusmonitor "$1" >/dev/null
|
||||
}
|
||||
|
||||
# togglespecialworkspace acts on the focused monitor, so focus decides where
|
||||
# the scratchpad appears (or which copy hides).
|
||||
toggle_on() {
|
||||
focus_monitor "$1"
|
||||
hyprctl dispatch "hl.dsp.workspace.toggle_special(\"$NAME\")" >/dev/null 2>&1 ||
|
||||
hyprctl dispatch togglespecialworkspace "$NAME" >/dev/null
|
||||
}
|
||||
|
||||
# The first connected home monitor, else whatever is focused.
|
||||
target_monitor() {
|
||||
hyprctl monitors -j | jq -r --arg m "$HOME_MONITOR" '
|
||||
. as $monitors
|
||||
| first(($m | split("|"))[] as $want | $monitors[] | select(.name == $want or .description == $want))
|
||||
// first($monitors[] | select(.focused))
|
||||
| .name'
|
||||
}
|
||||
|
||||
# Showing: just hide, even if a member is gone. Missing members come back on
|
||||
# the next reveal.
|
||||
shown=$(scratch_monitor)
|
||||
if [[ -n $shown ]]; then
|
||||
toggle_on "$shown"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
present=$(scratch_classes)
|
||||
before=$(grep -c . <<<"$present")
|
||||
launched=0
|
||||
for member in "${MEMBERS[@]}"; do
|
||||
pattern=${member%%|*}
|
||||
grep -qE "^(${pattern})$" <<<"$present" && continue
|
||||
# eval so a launch command can quote a desktop id with spaces.
|
||||
eval "setsid uwsm-app -- ${member#*|}" >/dev/null 2>&1 &
|
||||
launched=1
|
||||
done
|
||||
|
||||
if ((launched)); then
|
||||
# Reveal once the first newcomer lands (Claude takes a few seconds); any
|
||||
# later ones drop straight onto the now-visible scratchpad. Deciding the
|
||||
# reveal after the window lands keeps this correct whether or not the rule
|
||||
# already showed the workspace.
|
||||
for _ in {1..80}; do
|
||||
sleep 0.1
|
||||
(($(scratch_windows) > before)) && break
|
||||
done
|
||||
fi
|
||||
|
||||
scratch_visible || toggle_on "$(target_monitor)"
|
||||
Executable
+296
@@ -0,0 +1,296 @@
|
||||
#!/usr/bin/env python3
|
||||
"""omp-session — start or resume omp for the current git repo (ctrl-a in fish
|
||||
and zsh), and the backend of the television `omp-sessions` cable
|
||||
(~/.config/television/cable/omp-sessions.toml).
|
||||
|
||||
Launch rules, inside a git repo:
|
||||
default branch resume the newest session on it if it was written less than
|
||||
3 hours ago; otherwise pick one with `tv omp-sessions`
|
||||
(all sessions of the repo). No sessions at all → new session.
|
||||
other branch resume the newest session on it; none → new session.
|
||||
detached HEAD pick with `tv omp-sessions` (no sessions → new session).
|
||||
Outside a git repo: plain `omp`.
|
||||
|
||||
A session belongs to the repo when its cwd is the worktree root or below it,
|
||||
excluding worktrees nested inside it. omp does not record the git branch, so
|
||||
a session's branch is the branch HEAD was on (per `git reflog`) when the
|
||||
session file was last written.
|
||||
|
||||
Cable entry points:
|
||||
omp-session --list one "display<TAB>session file" record per session
|
||||
omp-session --preview FILE title, cwd, and the conversation's message text
|
||||
omp-session --resume FILE the cable's enter action: `omp --resume FILE`
|
||||
omp-session --new the cable's ctrl-n action: a new `omp` session
|
||||
|
||||
Env:
|
||||
OMP_SESSION_STATE=FILE --resume/--new write `ran` to it, so the launcher can
|
||||
tell a started session from a cancelled picker and
|
||||
only move the cursor back up after a cancel (tv
|
||||
--inline).
|
||||
PI_CODING_AGENT_DIR omp's agent dir (default ~/.omp/agent).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
RECENT = 3 * 3600
|
||||
SESSIONS = Path(os.environ.get("PI_CODING_AGENT_DIR") or Path.home() / ".omp" / "agent") / "sessions"
|
||||
|
||||
# tv closes the preview pipe early on long sessions; exit quietly.
|
||||
signal.signal(signal.SIGPIPE, signal.SIG_DFL)
|
||||
|
||||
MOVES = (
|
||||
# (pattern, group of the branch HEAD moved to, group of the branch it left)
|
||||
(re.compile(r"checkout: moving from (.+) to (.+)$"), 2, 1),
|
||||
(re.compile(r"Branch: renamed refs/heads/(.+) to refs/heads/(.+)$"), 2, 1),
|
||||
# rebase (start) detaches HEAD; the finish returns to the rebased branch.
|
||||
(re.compile(r"rebase.*\(finish\): returning to refs/heads/(.+)$"), 1, 1),
|
||||
)
|
||||
|
||||
|
||||
def git(*args):
|
||||
r = subprocess.run(["git", *args], capture_output=True, text=True)
|
||||
return r.stdout.strip() if r.returncode == 0 else ""
|
||||
|
||||
|
||||
def inside(path, root):
|
||||
return path == root or path.startswith(root.rstrip("/") + "/")
|
||||
|
||||
|
||||
class Session:
|
||||
__slots__ = ("path", "mtime", "cwd", "title")
|
||||
|
||||
def __init__(self, path, mtime, cwd, title):
|
||||
self.path, self.mtime, self.cwd, self.title = path, mtime, cwd, title
|
||||
|
||||
|
||||
def header(path):
|
||||
"""(cwd, title) from the head of a session file: an optional title record,
|
||||
then the session record."""
|
||||
title = None
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
for _, line in zip(range(4), fh):
|
||||
rec = json.loads(line)
|
||||
if rec.get("type") == "title":
|
||||
title = rec.get("title")
|
||||
elif rec.get("type") == "session":
|
||||
return rec.get("cwd"), title
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def repo_sessions(top):
|
||||
"""Sessions whose cwd is inside the worktree `top`, newest first."""
|
||||
nested = [
|
||||
line[len("worktree "):]
|
||||
for line in git("worktree", "list", "--porcelain").splitlines()
|
||||
if line.startswith("worktree ")
|
||||
]
|
||||
nested = [w for w in nested if w != top and inside(w, top)]
|
||||
out = []
|
||||
for path in SESSIONS.glob("*/*.jsonl"):
|
||||
h = header(path)
|
||||
if not h or not h[0]:
|
||||
continue
|
||||
cwd, title = h
|
||||
if not inside(cwd, top) or any(inside(cwd, w) for w in nested):
|
||||
continue
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
out.append(Session(path, mtime, cwd, title))
|
||||
out.sort(key=lambda s: s.mtime, reverse=True)
|
||||
return out
|
||||
|
||||
|
||||
class Timeline:
|
||||
"""Which branch HEAD was on at a given time, from this worktree's reflog."""
|
||||
|
||||
def __init__(self, current):
|
||||
self.current = current
|
||||
self.moves = [] # (unix time, to, from), newest first
|
||||
for line in git("reflog", "--date=unix", "--format=%gd%x09%gs").splitlines():
|
||||
ref, _, subject = line.partition("\t")
|
||||
m = re.search(r"@\{(\d+)\}", ref)
|
||||
if not m:
|
||||
continue
|
||||
for pat, to, frm in MOVES:
|
||||
hit = pat.search(subject)
|
||||
if hit:
|
||||
self.moves.append((int(m.group(1)), hit.group(to), hit.group(frm)))
|
||||
break
|
||||
|
||||
def branch_at(self, t):
|
||||
for ts, to, _ in self.moves:
|
||||
if ts <= t:
|
||||
return to
|
||||
# Older than every recorded move: the branch the oldest move left.
|
||||
return self.moves[-1][2] if self.moves else self.current
|
||||
|
||||
|
||||
def default_branch():
|
||||
ref = git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
|
||||
if ref:
|
||||
return ref.split("/", 1)[1]
|
||||
for b in (git("config", "init.defaultBranch"), "main", "master"):
|
||||
if b and git("rev-parse", "--verify", "--quiet", f"refs/heads/{b}"):
|
||||
return b
|
||||
return ""
|
||||
|
||||
|
||||
def first_user_text(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
for line in fh:
|
||||
if '"role":"user"' not in line:
|
||||
continue
|
||||
text = message_text(json.loads(line).get("message") or {})
|
||||
if text.strip():
|
||||
return text.strip().splitlines()[0]
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return ""
|
||||
|
||||
|
||||
def message_text(msg):
|
||||
content = msg.get("content")
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, list):
|
||||
return "\n".join(p.get("text", "") for p in content if isinstance(p, dict) and p.get("type") == "text")
|
||||
return ""
|
||||
|
||||
|
||||
def age(seconds):
|
||||
for unit, size in (("w", 604800), ("d", 86400), ("h", 3600), ("m", 60)):
|
||||
if seconds >= size:
|
||||
return f"{int(seconds // size)}{unit}"
|
||||
return "now"
|
||||
|
||||
|
||||
# ── Cable entry points ─────────────────────────────────────────────
|
||||
|
||||
|
||||
def cmd_list():
|
||||
top = git("rev-parse", "--show-toplevel")
|
||||
if not top:
|
||||
return
|
||||
sessions = repo_sessions(top)
|
||||
timeline = Timeline(git("branch", "--show-current"))
|
||||
now = time.time()
|
||||
rows = []
|
||||
for s in sessions:
|
||||
branch = timeline.branch_at(s.mtime)
|
||||
title = s.title or first_user_text(s.path) or s.path.stem
|
||||
sub = os.path.relpath(s.cwd, top)
|
||||
rows.append((age(now - s.mtime), branch, title + ("" if sub == "." else f" · {sub}"), s.path))
|
||||
width = min(max((len(r[1]) for r in rows), default=0), 24)
|
||||
for when, branch, title, path in rows:
|
||||
print(f"{when:>4} {branch[:width]:<{width}} {title}\t{path}")
|
||||
|
||||
|
||||
def cmd_preview(path):
|
||||
path = Path(path)
|
||||
h = header(path)
|
||||
cwd, title = h if h else ("", None)
|
||||
updated = time.strftime("%Y-%m-%d %H:%M", time.localtime(path.stat().st_mtime))
|
||||
print(f"\033[1m{title or path.stem}\033[0m")
|
||||
print(f"\033[2m{updated} · {cwd}\033[0m")
|
||||
colors = {"user": "\033[1;36m", "assistant": "\033[1;35m"}
|
||||
with open(path, encoding="utf-8", errors="replace") as fh:
|
||||
for line in fh:
|
||||
if '"type":"message"' not in line:
|
||||
continue
|
||||
try:
|
||||
msg = json.loads(line).get("message") or {}
|
||||
except ValueError:
|
||||
continue
|
||||
role = msg.get("role")
|
||||
text = message_text(msg).strip() if role in colors else ""
|
||||
if text:
|
||||
print(f"\n{colors[role]}▌ {role}\033[0m\n{text}")
|
||||
|
||||
|
||||
def mark_ran():
|
||||
state = os.environ.get("OMP_SESSION_STATE")
|
||||
if state:
|
||||
Path(state).write_text("ran\n")
|
||||
|
||||
|
||||
def cmd_resume(path):
|
||||
mark_ran()
|
||||
os.execvp("omp", ["omp", "--resume", str(path)])
|
||||
|
||||
|
||||
# ── Launcher ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def new_session():
|
||||
mark_ran()
|
||||
os.execvp("omp", ["omp"])
|
||||
|
||||
|
||||
def pick(sessions):
|
||||
if not sessions:
|
||||
new_session()
|
||||
fd, state = tempfile.mkstemp(prefix="omp-session.")
|
||||
os.close(fd)
|
||||
try:
|
||||
subprocess.run(["tv", "omp-sessions", "--inline"], env={**os.environ, "OMP_SESSION_STATE": state})
|
||||
ran = Path(state).read_text().strip() == "ran"
|
||||
finally:
|
||||
os.unlink(state)
|
||||
if not ran:
|
||||
# Cancelled: tv left the cursor on the line below the prompt.
|
||||
with open("/dev/tty", "w") as tty:
|
||||
tty.write("\033[A")
|
||||
|
||||
|
||||
def launch():
|
||||
top = git("rev-parse", "--show-toplevel")
|
||||
if not top:
|
||||
new_session()
|
||||
sessions = repo_sessions(top)
|
||||
branch = git("branch", "--show-current")
|
||||
if not branch:
|
||||
pick(sessions)
|
||||
return
|
||||
timeline = Timeline(branch)
|
||||
mine = next((s for s in sessions if timeline.branch_at(s.mtime) == branch), None)
|
||||
if branch == default_branch():
|
||||
if mine and time.time() - mine.mtime < RECENT:
|
||||
cmd_resume(mine.path)
|
||||
pick(sessions)
|
||||
elif mine:
|
||||
cmd_resume(mine.path)
|
||||
else:
|
||||
new_session()
|
||||
|
||||
|
||||
def main(argv):
|
||||
if not argv:
|
||||
launch()
|
||||
elif argv[0] == "--list":
|
||||
cmd_list()
|
||||
elif argv[0] == "--preview" and len(argv) == 2:
|
||||
cmd_preview(argv[1])
|
||||
elif argv[0] == "--resume" and len(argv) == 2:
|
||||
cmd_resume(argv[1])
|
||||
elif argv == ["--new"]:
|
||||
new_session()
|
||||
else:
|
||||
sys.exit("usage: omp-session [--list | --preview FILE | --resume FILE | --new]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1:])
|
||||
Executable
+450
@@ -0,0 +1,450 @@
|
||||
#!/usr/bin/env bash
|
||||
# pj — task runner across every task source in the project, picked with the
|
||||
# television `pj` cable (~/.config/television/cable/pj.toml).
|
||||
#
|
||||
# Sources: package.json scripts, Makefile targets, justfile recipes, mise
|
||||
# tasks, devbox scripts, and pyproject.toml task tables (poe, pdm, rye,
|
||||
# taskipy, hatch envs, console scripts).
|
||||
#
|
||||
# Scanning: cwd upward to the git toplevel (inclusive); outside a repo it
|
||||
# stops at the nearest directory that holds a task source. A source is only
|
||||
# listed when its runner is actually installed, so the list is never dead.
|
||||
# Tasks from the most recently modified manifest come first.
|
||||
#
|
||||
# Usage: pj [extra args…] extra args are forwarded to the picked task
|
||||
#
|
||||
# Env:
|
||||
# PJ_QUIET=1 suppress the "no task sources" error — the down-arrow
|
||||
# bindings use this, where an empty tree is the normal case.
|
||||
# PJ_STATE=FILE key bindings pass a path; pj writes `open` to it when the
|
||||
# picker opens and `pj --run` writes `ran`, so the binding
|
||||
# can tell "no tasks" / "cancelled" / "task printed output"
|
||||
# apart and redraw the prompt accordingly (tv --inline).
|
||||
#
|
||||
# Cable entry points (tab-separated record fields, see `--list`):
|
||||
# pj --list print one record per task
|
||||
# pj --preview KIND TARGET ROOT FILE render a task's body
|
||||
# pj --run KIND TARGET ROOT RUNNER run a task ($PJ_EXTRA holds extra args)
|
||||
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
# ── pyproject.toml reader ──────────────────────────────────────────
|
||||
# `list FILE` prints "tool<TAB>target" per task; `show FILE TARGET TOOL`
|
||||
# prints one task's body. tomllib is stdlib on python 3.11+.
|
||||
py_toml() {
|
||||
python3 - "$@" <<'PY' 2>/dev/null
|
||||
import json, sys, tomllib
|
||||
|
||||
mode, path = sys.argv[1], sys.argv[2]
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
data = tomllib.load(fh)
|
||||
except Exception:
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
def table(obj, *keys):
|
||||
for key in keys:
|
||||
if not isinstance(obj, dict):
|
||||
return {}
|
||||
obj = obj.get(key) or {}
|
||||
return obj if isinstance(obj, dict) else {}
|
||||
|
||||
|
||||
def tasks():
|
||||
"""(tool, target, body) for every supported pyproject task table."""
|
||||
out = []
|
||||
for tool, tbl in (
|
||||
("poe", table(data, "tool", "poe", "tasks")),
|
||||
("pdm", table(data, "tool", "pdm", "scripts")),
|
||||
("rye", table(data, "tool", "rye", "scripts")),
|
||||
("taskipy", table(data, "tool", "taskipy", "tasks")),
|
||||
):
|
||||
for name, body in tbl.items():
|
||||
if tool == "pdm" and (name.startswith("_") or name.startswith("post_")):
|
||||
continue # pdm's shared/hook keys are not runnable targets
|
||||
out.append((tool, name, body))
|
||||
|
||||
for env, cfg in table(data, "tool", "hatch", "envs").items():
|
||||
for name, body in table(cfg, "scripts").items():
|
||||
out.append(("hatch", f"{env}:{name}", body))
|
||||
|
||||
seen = set()
|
||||
for tbl in (table(data, "tool", "poetry", "scripts"), table(data, "project", "scripts")):
|
||||
for name, body in tbl.items():
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
out.append(("script", name, body))
|
||||
return out
|
||||
|
||||
|
||||
def render(body):
|
||||
if isinstance(body, str):
|
||||
return body
|
||||
if isinstance(body, list):
|
||||
return "\n".join(x if isinstance(x, str) else json.dumps(x) for x in body)
|
||||
if isinstance(body, dict):
|
||||
for key in ("cmd", "shell", "script", "expr", "ref"):
|
||||
if isinstance(body.get(key), str):
|
||||
return body[key]
|
||||
if isinstance(body.get("sequence"), list):
|
||||
return render(body["sequence"])
|
||||
return json.dumps(body, indent=2)
|
||||
return str(body)
|
||||
|
||||
|
||||
if mode == "list":
|
||||
for tool, name, _ in tasks():
|
||||
print(f"{tool}\t{name}")
|
||||
else:
|
||||
target, tool = sys.argv[3], sys.argv[4]
|
||||
for t, name, body in tasks():
|
||||
if name == target and t == tool:
|
||||
print(render(body))
|
||||
break
|
||||
PY
|
||||
}
|
||||
|
||||
# ── Preview ────────────────────────────────────────────────────────
|
||||
if [ "${1-}" = --preview ]; then
|
||||
kind=${2-} target=${3-} root=${4-} file=${5-}
|
||||
cd "$root" 2>/dev/null || exit 0
|
||||
case $kind in
|
||||
npm)
|
||||
jq -r --arg s "$target" '.scripts[$s] // "(no such script)"' "$file"
|
||||
;;
|
||||
make)
|
||||
awk -v t="$target" '
|
||||
!hit && /^[^\t]/ {
|
||||
ci = index($0, ":")
|
||||
if (ci > 0) {
|
||||
rest = substr($0, ci)
|
||||
if (rest !~ /^::?=/) {
|
||||
n = split(substr($0, 1, ci - 1), p, /[ \t]+/)
|
||||
for (i = 1; i <= n; i++)
|
||||
if (p[i] == t) { hit = 1; print; next }
|
||||
}
|
||||
}
|
||||
}
|
||||
hit && (/^\t/ || /^ +[^ ]/) { print; next }
|
||||
hit { exit }
|
||||
END { if (!hit) print "(no recipe — declared target only)" }
|
||||
' "$file"
|
||||
;;
|
||||
just)
|
||||
just --justfile "$file" --working-directory "$root" --show "$target" 2>&1
|
||||
;;
|
||||
mise)
|
||||
mise tasks info "$target" 2>&1
|
||||
;;
|
||||
devbox)
|
||||
jq -r --arg s "$target" \
|
||||
'.shell.scripts[$s] | if type == "array" then join("\n") else (. // "(no such script)") end' \
|
||||
"$file"
|
||||
;;
|
||||
*)
|
||||
py_toml show "$file" "$target" "$kind"
|
||||
;;
|
||||
esac
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Run (the cable's enter action) ────────────────────────────────
|
||||
if [ "${1-}" = --run ]; then
|
||||
kind=${2-} target=${3-} root=${4-} runner=${5-}
|
||||
cd "$root" || exit 1
|
||||
# Tell the key binding a task ran (its output now sits below the prompt).
|
||||
[ -n "${PJ_STATE-}" ] && echo ran >"$PJ_STATE"
|
||||
|
||||
# Extra args from `pj a b …`, shell-quoted into $PJ_EXTRA by the picker.
|
||||
extra=()
|
||||
[ -n "${PJ_EXTRA-}" ] && eval "extra=($PJ_EXTRA)"
|
||||
# Runners that need `--` to forward extra args to the task itself.
|
||||
if [ ${#extra[@]} -gt 0 ]; then
|
||||
case "$kind:$runner" in
|
||||
npm:'npm run' | mise:* | devbox:*) extra=(-- "${extra[@]}") ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2206 # word splitting is how the runner prefix is built
|
||||
cmd=(${runner} "$target" "${extra[@]}")
|
||||
|
||||
# Wrap long-running server tasks in openlogs (live log tail for agents).
|
||||
# Matches dev/start/serve/watch and namespaced variants like dev:web.
|
||||
wrapper=""
|
||||
if [[ $target =~ ^(dev|start|serve|watch)(:.*)?$ ]]; then
|
||||
# Resolve the binary: PATH → mise resolution → mise installs glob
|
||||
ol=$(command -v openlogs || true)
|
||||
if [ -z "$ol" ] && have mise; then
|
||||
ol=$(mise which openlogs 2>/dev/null || true)
|
||||
fi
|
||||
if [ -z "$ol" ]; then
|
||||
for cand in "$HOME"/.local/share/mise/installs/node/*/bin/openlogs; do
|
||||
[ -x "$cand" ] && { ol=$cand; break; }
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "$ol" ] && [ -x "$ol" ]; then
|
||||
wrapper=$ol
|
||||
else
|
||||
echo "pj: openlogs not found, running unwrapped" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -n "$wrapper" ]; then
|
||||
echo "→ $wrapper ${cmd[*]}" >&2
|
||||
exec "$wrapper" "${cmd[@]}"
|
||||
else
|
||||
echo "→ ${cmd[*]}" >&2
|
||||
exec "${cmd[@]}"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Collect targets ────────────────────────────────────────────────
|
||||
mise_config() {
|
||||
local d=$1 c
|
||||
for c in mise.toml .mise.toml mise/config.toml .mise/config.toml .config/mise/config.toml; do
|
||||
[ -f "$d/$c" ] && { echo "$d/$c"; return 0; }
|
||||
done
|
||||
for c in mise-tasks .mise/tasks mise/tasks; do
|
||||
[ -d "$d/$c" ] && { echo "$d/$c"; return 0; }
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Record layout (tab separated): kind, target, root, manifest, runner.
|
||||
recs=()
|
||||
add() { recs+=("$1"$'\t'"$2"$'\t'"$3"$'\t'"$4"$'\t'"$5"); }
|
||||
|
||||
collect_npm() {
|
||||
local dir=$1 pm d s
|
||||
local pkg=$dir/package.json
|
||||
[ -f "$pkg" ] || return
|
||||
|
||||
# Priority: packageManager field (corepack) → lockfiles (walking up past
|
||||
# $dir to catch monorepo-root locks) → npm fallback
|
||||
pm=$(jq -r '.packageManager // empty' "$pkg" 2>/dev/null)
|
||||
pm=${pm%%@*}
|
||||
if [ -z "$pm" ]; then
|
||||
d=$dir
|
||||
while [ -z "$pm" ]; do
|
||||
if [ -f "$d/bun.lockb" ] || [ -f "$d/bun.lock" ]; then
|
||||
pm=bun
|
||||
elif [ -f "$d/pnpm-lock.yaml" ]; then
|
||||
pm=pnpm
|
||||
elif [ -f "$d/yarn.lock" ]; then
|
||||
pm=yarn
|
||||
elif [ -f "$d/package-lock.json" ]; then
|
||||
pm=npm
|
||||
fi
|
||||
[ "$d" = / ] && break
|
||||
d=$(dirname "$d")
|
||||
done
|
||||
[ -n "$pm" ] || pm=npm
|
||||
fi
|
||||
have "$pm" || pm=npm
|
||||
have "$pm" || return
|
||||
|
||||
while IFS= read -r s; do
|
||||
[ -n "$s" ] && add npm "$s" "$dir" "$pkg" "$pm run"
|
||||
done < <(jq -r '.scripts // {} | keys[]' "$pkg" 2>/dev/null)
|
||||
}
|
||||
|
||||
collect_make() {
|
||||
local dir=$1 f="" c t
|
||||
have make || return
|
||||
for c in GNUmakefile makefile Makefile; do
|
||||
[ -f "$dir/$c" ] && { f=$dir/$c; break; }
|
||||
done
|
||||
[ -n "$f" ] || return
|
||||
|
||||
# Text parse: explicit rules plus .PHONY operands (targets built from
|
||||
# variables only show up in .PHONY). `make -p` is not used — it expands
|
||||
# $(shell ...) and would run code just to list names.
|
||||
local -A seen=()
|
||||
while IFS= read -r t; do
|
||||
[ -n "$t" ] && [ -z "${seen[$t]-}" ] || continue
|
||||
seen[$t]=1
|
||||
add make "$t" "$dir" "$f" make
|
||||
done < <(awk '
|
||||
/^\t/ { next }
|
||||
/^[ \t]*#/ { next }
|
||||
{
|
||||
line = $0
|
||||
sub(/#.*/, "", line)
|
||||
ci = index(line, ":")
|
||||
if (ci == 0) next
|
||||
lhs = substr(line, 1, ci - 1)
|
||||
rest = substr(line, ci + 1)
|
||||
if (substr(rest, 1, 1) == "=") next # := assignment
|
||||
if (lhs ~ /[$%=]/) next # variables, pattern rules
|
||||
n = split(lhs, parts, /[ \t]+/)
|
||||
if (parts[1] == ".PHONY") {
|
||||
sub(/^:+/, "", rest)
|
||||
n = split(rest, parts, /[ \t]+/)
|
||||
for (i = 1; i <= n; i++)
|
||||
if (parts[i] != "" && parts[i] !~ /[$%]/) print parts[i]
|
||||
next
|
||||
}
|
||||
for (i = 1; i <= n; i++)
|
||||
if (parts[i] != "" && parts[i] !~ /^\./) print parts[i]
|
||||
}
|
||||
' "$f")
|
||||
}
|
||||
|
||||
collect_just() {
|
||||
local dir=$1 f="" c t
|
||||
have just || return
|
||||
for c in justfile .justfile Justfile JUSTFILE; do
|
||||
[ -f "$dir/$c" ] && { f=$dir/$c; break; }
|
||||
done
|
||||
[ -n "$f" ] || return
|
||||
|
||||
for t in $(just --justfile "$f" --working-directory "$dir" --summary --unsorted 2>/dev/null); do
|
||||
add just "$t" "$dir" "$f" just
|
||||
done
|
||||
}
|
||||
|
||||
collect_mise() {
|
||||
local dir=$1 cfg t
|
||||
have mise || return
|
||||
cfg=$(mise_config "$dir") || return
|
||||
|
||||
# mise also inherits parent-directory configs; filter to tasks defined
|
||||
# under $dir so parents are not duplicated as we walk up.
|
||||
while IFS= read -r t; do
|
||||
[ -n "$t" ] && add mise "$t" "$dir" "$cfg" "mise run"
|
||||
done < <(mise -C "$dir" tasks ls --json 2>/dev/null |
|
||||
jq -r --arg dir "$dir/" '
|
||||
.[]
|
||||
| select((.hide // false) | not)
|
||||
| select((.source // "") | startswith($dir))
|
||||
| .name' 2>/dev/null)
|
||||
}
|
||||
|
||||
collect_devbox() {
|
||||
local dir=$1 s
|
||||
local f=$dir/devbox.json
|
||||
[ -f "$f" ] || return
|
||||
have devbox || return
|
||||
|
||||
while IFS= read -r s; do
|
||||
[ -n "$s" ] && add devbox "$s" "$dir" "$f" "devbox run"
|
||||
done < <(jq -r '.shell.scripts // {} | keys[]' "$f" 2>/dev/null)
|
||||
}
|
||||
|
||||
# Runner prefix for a pyproject task table, or empty when nothing can run it.
|
||||
# Tools that live in the project venv (poe, taskipy) fall back to `uv run`.
|
||||
py_runner() {
|
||||
case $1 in
|
||||
poe) have poe && echo poe || { have uv && echo "uv run poe"; } ;;
|
||||
taskipy) have task && echo task || { have uv && echo "uv run task"; } ;;
|
||||
pdm) have pdm && echo "pdm run" ;;
|
||||
rye) have rye && echo "rye run" ;;
|
||||
hatch) have hatch && echo "hatch run" ;;
|
||||
script)
|
||||
if have poetry && grep -q '^\[tool\.poetry' "$2"; then
|
||||
echo "poetry run"
|
||||
elif have uv; then
|
||||
echo "uv run"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
collect_py() {
|
||||
local dir=$1 tool target runner
|
||||
local f=$dir/pyproject.toml
|
||||
[ -f "$f" ] || return
|
||||
have python3 || return
|
||||
|
||||
local -A runners=()
|
||||
while IFS=$'\t' read -r tool target; do
|
||||
[ -n "$tool" ] && [ -n "$target" ] || continue
|
||||
if [ -z "${runners[$tool]-}" ]; then
|
||||
runners[$tool]=$(py_runner "$tool" "$f")
|
||||
[ -n "${runners[$tool]}" ] || runners[$tool]="-"
|
||||
fi
|
||||
runner=${runners[$tool]}
|
||||
[ "$runner" = - ] && continue
|
||||
add "$tool" "$target" "$dir" "$f" "$runner"
|
||||
done < <(py_toml list "$f")
|
||||
}
|
||||
|
||||
# Walk cwd upward: to the git toplevel inside a repo, otherwise until a
|
||||
# directory actually yields targets (a source file with no tasks in it must
|
||||
# not hide a parent's Makefile).
|
||||
git_top=$(git rev-parse --show-toplevel 2>/dev/null)
|
||||
d=$PWD
|
||||
while :; do
|
||||
collect_npm "$d"
|
||||
collect_make "$d"
|
||||
collect_just "$d"
|
||||
collect_mise "$d"
|
||||
collect_devbox "$d"
|
||||
collect_py "$d"
|
||||
|
||||
[ "$d" = "$git_top" ] && break
|
||||
[ "$d" = / ] && break
|
||||
[ -z "$git_top" ] && [ ${#recs[@]} -gt 0 ] && break
|
||||
d=$(dirname "$d")
|
||||
done
|
||||
|
||||
if [ ${#recs[@]} -eq 0 ]; then
|
||||
[ -n "${PJ_QUIET-}" ] || echo "pj: no task sources found in this tree" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ── List ───────────────────────────────────────────────────────────
|
||||
# Record (tab separated): display, kind, target, root, runner, manifest.
|
||||
# Tag each display with its kind (npm rows use the package manager) so `make`,
|
||||
# `just` or `poe` are typeable filters. The directory column only appears
|
||||
# when more than one project contributed. The manifest is the last field so
|
||||
# mtime-sort ranks tasks by their manifest's mtime.
|
||||
primary=${recs[0]#*$'\t'}
|
||||
primary=${primary#*$'\t'}
|
||||
primary=${primary%%$'\t'*}
|
||||
multi=0
|
||||
for r in "${recs[@]}"; do
|
||||
root=${r#*$'\t'}
|
||||
root=${root#*$'\t'}
|
||||
root=${root%%$'\t'*}
|
||||
[ "$root" = "$primary" ] || { multi=1; break; }
|
||||
done
|
||||
|
||||
list() {
|
||||
local r kind target root file runner tag display
|
||||
for r in "${recs[@]}"; do
|
||||
IFS=$'\t' read -r kind target root file runner <<<"$r"
|
||||
case $kind in
|
||||
npm) tag=${runner%% *} ;; # npm | pnpm | yarn | bun
|
||||
script) tag=py ;; # pyproject console entry points
|
||||
*) tag=$kind ;;
|
||||
esac
|
||||
if [ "$multi" = 1 ]; then
|
||||
display=$(printf '%-8s %-26s %s' "$tag" "$target" "${root##*/}")
|
||||
else
|
||||
display=$(printf '%-8s %s' "$tag" "$target")
|
||||
fi
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$display" "$kind" "$target" "$root" "$runner" "$file"
|
||||
done | mtime-sort
|
||||
}
|
||||
|
||||
if [ "${1-}" = --list ]; then
|
||||
list
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Pick ───────────────────────────────────────────────────────────
|
||||
# The scan already ran, so hand tv the result instead of letting the cable
|
||||
# re-run `pj --list`; tv's enter action calls `pj --run`.
|
||||
tmp=$(mktemp "${TMPDIR:-/tmp}/pj.XXXXXX") || exit 1
|
||||
trap 'rm -f "$tmp"' EXIT
|
||||
list >"$tmp"
|
||||
if [ $# -gt 0 ]; then
|
||||
PJ_EXTRA=$(printf '%q ' "$@")
|
||||
export PJ_EXTRA
|
||||
fi
|
||||
[ -n "${PJ_STATE-}" ] && echo open >"$PJ_STATE"
|
||||
tv pj --inline --source-command "cat '$tmp'" --input-header "$primary"
|
||||
Executable
+244
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/env bash
|
||||
# pj-palette — pj's task runner as a tmux-palette source (the "tasks" palette,
|
||||
# ~/.config/tmux-palette/palettes/tasks.json).
|
||||
#
|
||||
# pj-palette print palette items (JSON) for the active pane's
|
||||
# project: running background tasks, quick actions
|
||||
# (install, dev, sync, make, compose up, …) and
|
||||
# every task `pj --list` finds
|
||||
# pj-palette --run KIND TARGET ROOT RUNNER
|
||||
# run a pj task (the item's action)
|
||||
# pj-palette --cmd ROOT CMD…
|
||||
# run a quick-action command in ROOT
|
||||
# pj-palette --bg NAME [ROOT CMD…]
|
||||
# show background session NAME in a popup, first
|
||||
# starting CMD… in ROOT if it isn't running
|
||||
# pj-palette --omp print the omp palette's items: this tmux
|
||||
# session's omp (open or start), every other
|
||||
# running <session>:omp, and Quit items
|
||||
#
|
||||
# One-off items open a full-width split along the bottom of the window (25% of
|
||||
# its height) in the project root. The command runs in the foreground; ctrl-c
|
||||
# stops it (the output stays). Once it has exited, enter or ctrl-c closes the
|
||||
# pane.
|
||||
#
|
||||
# Long-running items (dev/start/serve/watch/preview tasks, compose up) run in a
|
||||
# detached session named <session>:<tool-and-command> (leetcode:bun-run-dev),
|
||||
# shown in a popup: q hides the popup and leaves the task running, ctrl-c
|
||||
# asks for confirmation, then stops the task and ends its session. Running
|
||||
# ones are listed first, under Running, to bring them back. tmux.conf's
|
||||
# prefix + a starts omp the same way (<session>:omp); omp needs q, so its
|
||||
# popup hides with ctrl-c (or prefix + a) and ctrl-q quits it.
|
||||
|
||||
set -uo pipefail
|
||||
self=$(readlink -f "$0")
|
||||
|
||||
# While the task runs, ctrl-c reaches only the task: with SIGINT trapped,
|
||||
# bash survives the child's death instead of exiting along with it.
|
||||
interrupted=
|
||||
trap 'interrupted=1' INT
|
||||
|
||||
finish() {
|
||||
local status=$1 key
|
||||
# A background task stopped with ctrl-c ends its session (and popup).
|
||||
[ -n "${PJ_BG-}" ] && [ -n "$interrupted" ] && exit 0
|
||||
printf '\n\033[2m[exit %s] enter or ctrl-c to close\033[0m' "$status"
|
||||
trap 'exit 0' INT
|
||||
while IFS= read -rsn1 key; do
|
||||
[ -z "$key" ] && exit 0 # enter
|
||||
done
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Id of session NAME. Background session names contain ':', which -t reads
|
||||
# as session:window, so they are targeted by id.
|
||||
session_id() {
|
||||
local id name
|
||||
while IFS=$'\t' read -r id name; do
|
||||
[ "$name" = "$1" ] && { printf '%s' "$id"; return 0; }
|
||||
done < <(tmux list-sessions -F $'#{session_id}\t#{session_name}' 2>/dev/null)
|
||||
return 1
|
||||
}
|
||||
|
||||
case ${1-} in
|
||||
--run)
|
||||
shift
|
||||
pj --run "$@"
|
||||
finish $?
|
||||
;;
|
||||
--cmd)
|
||||
cd "$2" || exit 1
|
||||
shift 2
|
||||
printf '→ %s\n' "$*" >&2
|
||||
"$@"
|
||||
finish $?
|
||||
;;
|
||||
--bg)
|
||||
name=$2
|
||||
if ! id=$(session_id "$name"); then
|
||||
[ $# -ge 4 ] || exit 0 # a Running item whose task has since ended
|
||||
root=$3
|
||||
shift 3
|
||||
id=$(tmux new-session -d -P -F '#{session_id}' -s "$name" -c "$root" -e PJ_BG=1 "$@") || exit 1
|
||||
# @pj_task marks the session for the Running list (and for tmux.conf's
|
||||
# prefix + a, which hides any of these popups).
|
||||
tmux set -t "$id" @pj_task "$root" \; set -t "$id" status off \; \
|
||||
set -t "$id" detach-on-destroy on
|
||||
# Each session gets a key table for its hide/quit keys; other keys
|
||||
# reach the program. A task's output takes no input, so q hides and
|
||||
# ctrl-c (after a y/n confirm) stops the task. Interactive programs
|
||||
# (omp) need q and typing, so ctrl-c hides and ctrl-q kills the session.
|
||||
if [ "$1" = "$self" ]; then
|
||||
tmux set -t "$id" key-table pj-task \; bind -T pj-task q detach-client \; \
|
||||
bind -T pj-task C-c confirm-before -p 'Stop #{session_name}? (y/n)' 'send-keys C-c'
|
||||
else
|
||||
tmux set -t "$id" key-table pj-app \; bind -T pj-app C-c detach-client \; \
|
||||
bind -T pj-app C-q kill-session
|
||||
fi
|
||||
fi
|
||||
# Task output gets a big centered popup; interactive programs (omp) dock
|
||||
# bottom-right beside the work. Positions past the client edge clamp to
|
||||
# it (-x R would follow whichever pane tmux picks as current).
|
||||
geometry=(-w 90% -h 80%)
|
||||
[ "$(tmux display -p -t "$id" '#{key-table}')" = pj-app ] &&
|
||||
geometry=(-w 35% -h 60% -x '#{client_width}' -y '#{client_height}')
|
||||
tmux display-popup -E "${geometry[@]}" -b rounded -T " $name " "tmux attach -t '$id'"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
cwd=$(tmux display -p '#{pane_current_path}' 2>/dev/null) || cwd=$PWD
|
||||
session=$(tmux display -p '#{session_name}' 2>/dev/null)
|
||||
cd "$cwd" || exit 1
|
||||
root=$(git rev-parse --show-toplevel 2>/dev/null) || root=$cwd
|
||||
|
||||
icon_for() {
|
||||
case $1 in
|
||||
bun) printf '' ;;
|
||||
npm | yarn) printf '' ;;
|
||||
pnpm) printf '' ;;
|
||||
make) printf '' ;;
|
||||
go) printf '' ;;
|
||||
cargo) printf '' ;;
|
||||
uv | py | poe | pdm | rye | taskipy | hatch | script) printf '' ;;
|
||||
*) printf '' ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# Tasks that run until stopped: dev/start/serve/watch/preview, also as a
|
||||
# namespace segment (dev:web, api:dev).
|
||||
long_running() { [[ $1 =~ (^|:)(dev|start|serve|watch|preview)(:|$) ]]; }
|
||||
slug() { local s=${*,,}; s=${s//[^a-z0-9]/-}; tr -s - <<<"$s"; }
|
||||
|
||||
# Item action (a bash line the palette evals) running `pj-palette ARGV…`:
|
||||
# in a full-width bottom split (25%) in DIR, or, given NAME, in the background
|
||||
# session <session>:NAME shown in a popup. Argv goes to tmux as is (no shell,
|
||||
# so fish re-parses nothing).
|
||||
task_action() { # dir name argv…
|
||||
local dir=$1 name=$2
|
||||
shift 2
|
||||
if [ -n "$name" ]; then
|
||||
printf '%q ' "$self" --bg "$session:$name" "$dir" "$self" "$@"
|
||||
else
|
||||
printf '%q ' tmux split-window -v -f -l 25% -c "$dir" "$self" "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
items=()
|
||||
item() { # category icon title description aliases(csv) action [icon color]
|
||||
items+=("$(jq -cn --arg c "$1" --arg i "$2" --arg t "$3" --arg d "$4" --arg a "$5" --arg x "$6" --arg k "${7-}" \
|
||||
'{category: $c, icon: $i, title: $t, description: $d,
|
||||
aliases: ($a | split(",") | map(select(. != ""))), action: {shell: $x}}
|
||||
+ if $k == "" then {} else {iconColor: $k} end')")
|
||||
}
|
||||
quick() { # icon title aliases background(0/1) dir argv…
|
||||
local icon=$1 title=$2 aliases=$3 dir=$5 name="" desc="in ${5/#$HOME/\~}"
|
||||
[ "$4" = 1 ] && { name=$(slug "$title"); desc+=" · background"; }
|
||||
shift 5
|
||||
item Quick "$icon" "$title" "$desc" "$aliases" "$(task_action "$dir" "$name" --cmd "$dir" "$@")"
|
||||
}
|
||||
|
||||
# ── omp palette (palettes/omp.json): the <session>:omp agent sessions ──────
|
||||
if [ "${1-}" = --omp ]; then
|
||||
here=$session:omp live='#5eff6c' idle='#7b8496'
|
||||
omps=$(tmux list-sessions -F $'#{session_id}\t#{session_name}\t#{@pj_task}\t#{session_attached}' 2>/dev/null |
|
||||
awk -F'\t' '$2 ~ /:omp$/ && $3 != ""')
|
||||
state() { [ "$1" -gt 0 ] && printf open || printf hidden; }
|
||||
found=
|
||||
while IFS=$'\t' read -r id name dir attached; do
|
||||
[ "$name" = "$here" ] || continue
|
||||
found=1
|
||||
item "This session" '' "$name" "$(state "$attached") · in ${dir/#$HOME/\~}" "" \
|
||||
"$(printf '%q ' "$self" --bg "$name")" "$live"
|
||||
done <<<"$omps"
|
||||
[ -n "$found" ] || item "This session" '' "$here" "not running · start in ${cwd/#$HOME/\~}" "new" \
|
||||
"$(printf '%q ' "$self" --bg "$here" "$cwd" omp)" "$idle"
|
||||
while IFS=$'\t' read -r id name dir attached; do
|
||||
[ -n "$name" ] && [ "$name" != "$here" ] || continue
|
||||
item Running '' "$name" "$(state "$attached") · in ${dir/#$HOME/\~}" "" \
|
||||
"$(printf '%q ' "$self" --bg "$name")" "$live"
|
||||
done <<<"$omps"
|
||||
while IFS=$'\t' read -r id name dir attached; do
|
||||
[ -n "$name" ] && item Quit '' "Quit $name" "kill the session" "kill" \
|
||||
"$(printf '%q ' tmux kill-session -t "$id")"
|
||||
done <<<"$omps"
|
||||
printf '%s\n' "${items[@]}" | jq -s .
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ── Background tasks still running (any project), to bring back ──────────
|
||||
while IFS=$'\t' read -r name dir; do
|
||||
[ -n "$dir" ] && item Running '' "$name" "in ${dir/#$HOME/\~}" "" "$(printf '%q ' "$self" --bg "$name")"
|
||||
done < <(tmux list-sessions -F $'#{session_name}\t#{@pj_task}' 2>/dev/null)
|
||||
|
||||
# ── Quick actions, from the files at the project root ─────────────────────
|
||||
if [ -f "$root/package.json" ]; then
|
||||
pm=$(jq -r '.packageManager // empty' "$root/package.json" 2>/dev/null)
|
||||
pm=${pm%%@*}
|
||||
if [ -z "$pm" ]; then
|
||||
if [ -f "$root/bun.lock" ] || [ -f "$root/bun.lockb" ]; then pm=bun
|
||||
elif [ -f "$root/pnpm-lock.yaml" ]; then pm=pnpm
|
||||
elif [ -f "$root/yarn.lock" ]; then pm=yarn
|
||||
else pm=npm
|
||||
fi
|
||||
fi
|
||||
if have "$pm"; then
|
||||
quick "$(icon_for "$pm")" "$pm install" "install,i" 0 "$root" "$pm" install
|
||||
jq -e '.scripts.dev' "$root/package.json" >/dev/null 2>&1 &&
|
||||
quick "$(icon_for "$pm")" "$pm run dev" "dev" 1 "$root" "$pm" run dev
|
||||
fi
|
||||
fi
|
||||
if [ -f "$root/pyproject.toml" ] && have uv; then
|
||||
quick "$(icon_for uv)" "uv sync" "sync,install" 0 "$root" uv sync
|
||||
quick "$(icon_for uv)" "uv run python" "py,repl" 0 "$root" uv run python
|
||||
fi
|
||||
if have make && { [ -f "$root/Makefile" ] || [ -f "$root/makefile" ] || [ -f "$root/GNUmakefile" ]; }; then
|
||||
quick "$(icon_for make)" "make" "m,build" 0 "$root" make
|
||||
fi
|
||||
[ -f "$root/go.mod" ] && have go && quick "$(icon_for go)" "go run ." "run" 0 "$root" go run .
|
||||
[ -f "$root/Cargo.toml" ] && have cargo && quick "$(icon_for cargo)" "cargo run" "run" 0 "$root" cargo run
|
||||
if have docker; then
|
||||
for f in compose.yaml compose.yml docker-compose.yaml docker-compose.yml; do
|
||||
[ -f "$root/$f" ] || continue
|
||||
quick '' "docker compose up" "up,compose" 1 "$root" docker compose up
|
||||
break
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Every task pj finds (npm/bun/pnpm scripts, make, just, mise, devbox,
|
||||
# pyproject task tables and console scripts) ─────────────────────────────
|
||||
while IFS=$'\t' read -r display kind target troot runner _file; do
|
||||
[ -n "$kind" ] || continue
|
||||
tag=${display%% *}
|
||||
desc="$runner $target" name=""
|
||||
[ "$troot" != "$root" ] && desc+=" (${troot#"$root"/})"
|
||||
long_running "$target" && { name=$(slug "$runner $target"); desc+=" · background"; }
|
||||
item "Tasks: $tag" "$(icon_for "$tag")" "$target" "$desc" "" \
|
||||
"$(task_action "$troot" "$name" --run "$kind" "$target" "$troot" "$runner")"
|
||||
done < <(PJ_QUIET=1 pj --list 2>/dev/null)
|
||||
|
||||
if [ ${#items[@]} -eq 0 ]; then
|
||||
item Tasks '' "No tasks found" "in ${cwd/#$HOME/\~}" "" "tmux display-message 'pj: no task sources here'"
|
||||
fi
|
||||
printf '%s\n' "${items[@]}" | jq -s .
|
||||
Executable
+36
@@ -0,0 +1,36 @@
|
||||
#!/bin/bash
|
||||
# Mirror every repo under the ghq root (~/Code/<host>/<owner>/<repo>) as a
|
||||
# symlink ~/Projects/<repo>. Repos sharing a name across owners get
|
||||
# <owner>-<repo>. Links into ~/Code whose repo is gone are removed; anything
|
||||
# in ~/Projects that is not such a link is never touched. Run by
|
||||
# projects-sync.timer (systemd user).
|
||||
set -euo pipefail
|
||||
|
||||
code=${GHQ_ROOT:-$HOME/Code}
|
||||
projects=$HOME/Projects
|
||||
mkdir -p "$projects"
|
||||
|
||||
mapfile -t repos < <(find "$code" -mindepth 4 -maxdepth 4 -name .git -printf '%h\n' 2>/dev/null | sort)
|
||||
|
||||
declare -A count keep
|
||||
for repo in "${repos[@]}"; do
|
||||
name=${repo##*/}
|
||||
count[$name]=$((${count[$name]:-0} + 1))
|
||||
done
|
||||
|
||||
for repo in "${repos[@]}"; do
|
||||
name=${repo##*/}
|
||||
if ((count[$name] > 1)); then
|
||||
owner=${repo%/*}
|
||||
name=${owner##*/}-$name
|
||||
fi
|
||||
link=$projects/$name
|
||||
keep[$name]=1
|
||||
[[ -e $link && ! -L $link ]] && continue
|
||||
[[ $(readlink "$link") == "$repo" ]] || ln -sfn "$repo" "$link"
|
||||
done
|
||||
|
||||
for link in "$projects"/*; do
|
||||
[[ -L $link && $(readlink "$link") == "$code"/* && -z ${keep[${link##*/}]:-} ]] && rm "$link"
|
||||
done
|
||||
exit 0
|
||||
Executable
+95
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# tmux-sessions — tmux session switcher as a tmux-palette source (the
|
||||
# "sessions" palette, ~/.config/tmux-palette/palettes/sessions.json).
|
||||
#
|
||||
# tmux-sessions print palette items (JSON): the other sessions
|
||||
# (last one first), every window of every session,
|
||||
# zoxide's directories, ~/.config's directories,
|
||||
# and a Rename and a Kill item per session
|
||||
# tmux-sessions --new QUERY the palette's query item (what was typed): switch
|
||||
# to session QUERY, or start it in QUERY if that is
|
||||
# a directory, else in zoxide's best match for it
|
||||
# tmux-sessions --dir DIR switch to, or start in DIR, the session named
|
||||
# after DIR's basename without dots
|
||||
#
|
||||
# pj-palette's background task sessions (@pj_task) are left out. Sessions are
|
||||
# targeted by id since names may contain ':'.
|
||||
|
||||
set -uo pipefail
|
||||
self=$(readlink -f "$0")
|
||||
|
||||
session_id() { # name
|
||||
local id name
|
||||
while IFS=$'\t' read -r id name; do
|
||||
[ "$name" = "$1" ] && { printf '%s' "$id"; return 0; }
|
||||
done < <(tmux list-sessions -F $'#{session_id}\t#{session_name}' 2>/dev/null)
|
||||
return 1
|
||||
}
|
||||
|
||||
# Switch to session NAME, first starting it in DIR (when given) if missing.
|
||||
open() { # name [dir]
|
||||
local id
|
||||
id=$(session_id "$1") ||
|
||||
id=$(tmux new-session -d -P -F '#{session_id}' -s "$1" ${2:+-c "$2"}) || exit 1
|
||||
tmux switch-client -t "$id"
|
||||
}
|
||||
|
||||
case ${1-} in
|
||||
--dir)
|
||||
dir=${2%/}
|
||||
name=${dir##*/}
|
||||
open "${name//./}" "$2"
|
||||
exit
|
||||
;;
|
||||
--new)
|
||||
query=$2
|
||||
[ -d "${query/#\~/$HOME}" ] && exec "$self" --dir "${query/#\~/$HOME}"
|
||||
dir=
|
||||
if ! session_id "$query" >/dev/null; then
|
||||
read -ra words <<<"$query"
|
||||
dir=$(zoxide query -- "${words[@]}" 2>/dev/null)
|
||||
fi
|
||||
open "$query" "$dir"
|
||||
exit
|
||||
;;
|
||||
esac
|
||||
|
||||
{
|
||||
tmux list-sessions -F $'S\t#{session_id}\t#{session_name}\t#{@pj_task}\t#{session_attached}\t#{session_windows}\t#{session_path}'
|
||||
tmux list-windows -a -F $'W\t#{session_id}\t#{session_name}\t#{@pj_task}\t#{window_id}\t#{window_index}\t#{window_name}\t#{pane_current_path}'
|
||||
zoxide query -l 2>/dev/null | sed 's/^/Z\t/'
|
||||
find -L "$HOME/.config" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort | sed 's/^/C\t/'
|
||||
} | jq -Rn --arg self "$self" --arg home "$HOME" \
|
||||
--arg cur "$(tmux display -p '#{session_id}')" --arg last "$(tmux display -p '#{client_last_session}')" '
|
||||
def tilde: if startswith($home) then "~" + .[($home | length):] else . end;
|
||||
def session_name: rtrimstr("/") | split("/") | last | gsub("\\."; "");
|
||||
[inputs | split("\t")] as $rows
|
||||
| ($rows | map(select(.[0] == "S" and .[3] == ""))) as $sessions
|
||||
| ($rows | map(select(.[0] == "S") | .[2])) as $names
|
||||
| ($rows | map(select(.[0] == "Z") | .[1])) as $zoxide
|
||||
| ($sessions | map(select(.[1] != $cur))) as $others
|
||||
| def dir($category; $icon):
|
||||
(.[1] | session_name) as $name
|
||||
| {category: $category, icon: $icon, title: (.[1] | tilde),
|
||||
description: (if $names | any(. == $name) then "switch to \($name)" else "new session \($name)" end),
|
||||
action: {shell: "\($self | @sh) --dir \(.[1] | @sh)"}};
|
||||
[
|
||||
(($others | map(select(.[2] == $last)) + map(select(.[2] != $last)))[]
|
||||
| {category: "Sessions", icon: "", title: .[2],
|
||||
description: "\(.[5]) window\(if .[5] == "1" then "" else "s" end)\(if .[4] != "0" then " · attached" else "" end) · \(.[6] | tilde)",
|
||||
action: {tmux: "switch-client -t \(.[1] | @sh)"}}
|
||||
+ (if .[4] != "0" then {iconColor: "#5eff6c"} else {} end)),
|
||||
($rows[] | select(.[0] == "W" and .[3] == "")
|
||||
| (.[6] | capture("^(?<icon>[^\\x00-\\x7f]) (?<title>.*)$") // {icon: "", title: .}) as $w
|
||||
| {category: "Windows", icon: $w.icon, title: $w.title,
|
||||
description: "\(.[2]):\(.[5]) · \(.[7] | tilde)",
|
||||
action: {tmux: "switch-client -t \("\(.[1]):\(.[4])" | @sh)"}}),
|
||||
($rows[] | select(.[0] == "Z") | dir("Zoxide"; "")),
|
||||
($rows[] | select(.[0] == "C" and (.[1] as $d | $zoxide | any(. == $d) | not)) | dir("Config"; "")),
|
||||
($sessions[]
|
||||
| {category: "Rename", icon: "", title: "Rename \(.[2])", description: "rename the session",
|
||||
action: {tmux: "command-prompt -I \(.[2] | @sh) -p \("Rename \(.[2]):" | @sh) \("rename-session -t \(.[1] | @sh) -- \"%%%\"" | @sh)"}}),
|
||||
($others[]
|
||||
| {category: "Kill", icon: "", title: "Kill \(.[2])", description: "kill the session",
|
||||
action: {tmux: "kill-session -t \(.[1] | @sh)"}})
|
||||
]'
|
||||
Executable
+26
@@ -0,0 +1,26 @@
|
||||
#!/bin/sh
|
||||
# Yazi `edit` opener. Inside a Neovim terminal ($NVIM set) files open in the
|
||||
# HOST instance via _G.EditFromYazi (see ~/.config/nvim/lua/config/yazi_session.lua)
|
||||
# and the yazi window hides itself; standalone yazi gets a regular blocking nvim.
|
||||
#
|
||||
# usage: nvim-open.sh [vsplit|split|tabedit] file... (default mode: edit)
|
||||
|
||||
case "$1" in
|
||||
vsplit | split | tabedit) mode="$1"; shift ;;
|
||||
*) mode="edit" ;;
|
||||
esac
|
||||
|
||||
[ -n "$YAZI_OPEN_TRACE" ] && printf 'NVIM=%s MODE=%s ARGS=%s\n' "$NVIM" "$mode" "$*" >> "$YAZI_OPEN_TRACE"
|
||||
|
||||
if [ -z "$NVIM" ]; then
|
||||
# split modes are meaningless outside an nvim terminal — quiet no-op
|
||||
[ "$mode" = "edit" ] || exit 0
|
||||
exec nvim "$@"
|
||||
fi
|
||||
|
||||
for f in "$@"; do
|
||||
abs=$(realpath -- "$f") || continue
|
||||
# escape for a vimscript double-quoted string
|
||||
esc=$(printf '%s' "$abs" | sed 's/[\\"]/\\&/g')
|
||||
nvim --server "$NVIM" --remote-expr "v:lua.EditFromYazi(\"$mode\", \"$esc\")"
|
||||
done
|
||||
Reference in new issue
Block a user