muse.plugin.zsh
| 1 | # muse.plugin.zsh — Oh My ZSH plugin for Muse version control |
| 2 | # ============================================================================== |
| 3 | # Minimal, secure shell integration. Shows domain + branch in your prompt. |
| 4 | # Nothing else runs automatically; everything else is a muse command away. |
| 5 | # |
| 6 | # Setup (after running tools/install-omzsh-plugin.sh): |
| 7 | # Add $(muse_prompt_info) to your PROMPT in ~/.zshrc, e.g.: |
| 8 | # PROMPT='%~ $(muse_prompt_info) %# ' |
| 9 | # |
| 10 | # Configuration (set in ~/.zshrc BEFORE plugins=(… muse …)): |
| 11 | # MUSE_PROMPT_ICONS=1 Use emoji icons; set 0 for plain ASCII (default 1) |
| 12 | # MUSE_DIRTY_TIMEOUT=1 Seconds before dirty-check gives up (default 1) |
| 13 | # |
| 14 | # Security notes: |
| 15 | # - No eval of any data read from disk or env. |
| 16 | # - Branch names are regex-validated and %-escaped before prompt display. |
| 17 | # - Domain name is validated as alphanumeric before use. |
| 18 | # - All repo paths passed to subprocesses via env vars (not -c strings). |
| 19 | # - Dirty check runs only after a muse command, never on every keystroke. |
| 20 | # - Zero subprocesses on prompt render; one python3 on directory change. |
| 21 | # ============================================================================== |
| 22 | |
| 23 | autoload -Uz is-at-least |
| 24 | if ! is-at-least 5.0; then |
| 25 | print "[muse] ZSH 5.0+ required. Plugin not loaded." >&2 |
| 26 | return 1 |
| 27 | fi |
| 28 | |
| 29 | # ── Configuration ───────────────────────────────────────────────────────────── |
| 30 | : ${MUSE_PROMPT_ICONS:=0} |
| 31 | : ${MUSE_DIRTY_TIMEOUT:=5} |
| 32 | |
| 33 | # Domain icon map. Override individual elements in ~/.zshrc before plugins=(). |
| 34 | typeset -gA MUSE_DOMAIN_ICONS |
| 35 | MUSE_DOMAIN_ICONS=( |
| 36 | midi "♪" |
| 37 | code "⌥" |
| 38 | bitcoin "₿" |
| 39 | scaffold "⬡" |
| 40 | _default "◈" |
| 41 | ) |
| 42 | |
| 43 | # ── Internal state ──────────────────────────────────────────────────────────── |
| 44 | typeset -g MUSE_REPO_ROOT="" # absolute path to repo root, or "" |
| 45 | typeset -g MUSE_DOMAIN="midi" # active domain plugin name |
| 46 | typeset -g MUSE_BRANCH="" # branch name, 8-char SHA, or "?" |
| 47 | typeset -gi MUSE_DIRTY=0 # 1 when working tree has uncommitted changes |
| 48 | typeset -gi MUSE_DIRTY_COUNT=0 # number of changed paths |
| 49 | typeset -gi _MUSE_CMD_RAN=0 # 1 after any muse command runs |
| 50 | |
| 51 | # ── §1 Core detection (zero subprocesses) ─────────────────────────────────── |
| 52 | |
| 53 | # Walk up from $PWD to find a valid Muse repo root. Sets MUSE_REPO_ROOT. |
| 54 | # A valid repo requires .muse/repo.json — a bare .muse/ directory is not |
| 55 | # enough. This prevents false positives from stray or partial .muse/ dirs |
| 56 | # (e.g. a forgotten muse init in a parent directory). |
| 57 | function _muse_find_root() { |
| 58 | local dir="$PWD" |
| 59 | while [[ "$dir" != "/" ]]; do |
| 60 | if [[ -f "$dir/.muse/repo.json" ]]; then |
| 61 | MUSE_REPO_ROOT="$dir" |
| 62 | return 0 |
| 63 | fi |
| 64 | dir="${dir:h}" |
| 65 | done |
| 66 | MUSE_REPO_ROOT="" |
| 67 | return 1 |
| 68 | } |
| 69 | |
| 70 | # Read branch from .muse/HEAD without forking. Validates before storing. |
| 71 | # Branch names are restricted to [a-zA-Z0-9/_.-] to prevent prompt injection. |
| 72 | # |
| 73 | # Muse HEAD format (canonical, written by muse/core/store.py): |
| 74 | # ref: refs/heads/<branch> — on a branch (symbolic ref) |
| 75 | # commit: <sha256> — detached HEAD (direct commit reference) |
| 76 | function _muse_parse_head() { |
| 77 | local head_file="$MUSE_REPO_ROOT/.muse/HEAD" |
| 78 | if [[ ! -f "$head_file" ]]; then |
| 79 | MUSE_BRANCH=""; return 1 |
| 80 | fi |
| 81 | local raw |
| 82 | raw=$(<"$head_file") |
| 83 | if [[ "$raw" == "ref: refs/heads/"* ]]; then |
| 84 | local branch="${raw#ref: refs/heads/}" |
| 85 | # Reject anything that could inject prompt escapes or path components. |
| 86 | if [[ "$branch" =~ '^[[:alnum:]/_.-]+$' ]]; then |
| 87 | MUSE_BRANCH="$branch" |
| 88 | else |
| 89 | MUSE_BRANCH="?" |
| 90 | fi |
| 91 | elif [[ "$raw" == "commit: "* ]]; then |
| 92 | local sha="${raw#commit: }" |
| 93 | MUSE_BRANCH="${sha:0:8}" # detached HEAD — show short SHA |
| 94 | else |
| 95 | MUSE_BRANCH="?" |
| 96 | fi |
| 97 | } |
| 98 | |
| 99 | # Read domain from .muse/repo.json. One python3 call; path via env var only. |
| 100 | function _muse_parse_domain() { |
| 101 | local repo_json="$MUSE_REPO_ROOT/.muse/repo.json" |
| 102 | if [[ ! -f "$repo_json" ]]; then |
| 103 | MUSE_DOMAIN="midi"; return |
| 104 | fi |
| 105 | MUSE_DOMAIN=$(MUSE_REPO_JSON="$repo_json" python3 <<'PYEOF' 2>/dev/null |
| 106 | import json, os |
| 107 | try: |
| 108 | d = json.load(open(os.environ['MUSE_REPO_JSON'])) |
| 109 | v = str(d.get('domain', 'midi')) |
| 110 | # Accept only safe domain names: alphanumeric plus hyphens/underscores, |
| 111 | # max 32 chars. Anything else falls back to 'midi'. |
| 112 | safe = v.replace('-', '').replace('_', '') |
| 113 | print(v if (safe.isalnum() and 1 <= len(v) <= 32) else 'midi') |
| 114 | except Exception: |
| 115 | print('midi') |
| 116 | PYEOF |
| 117 | ) |
| 118 | : ${MUSE_DOMAIN:=midi} |
| 119 | } |
| 120 | |
| 121 | # Check dirty state. Runs with timeout; called on cd, shell load, and after |
| 122 | # any muse command. MUSE_DIRTY_TIMEOUT (default 5s) caps the wait. |
| 123 | typeset -gi _MUSE_LAST_DIRTY_RC=0 # last exit code from the dirty check subprocess |
| 124 | |
| 125 | function _muse_check_dirty() { |
| 126 | local output rc count=0 |
| 127 | output=$(cd -- "$MUSE_REPO_ROOT" && \ |
| 128 | timeout -- "${MUSE_DIRTY_TIMEOUT}" muse status --porcelain 2>/dev/null) |
| 129 | rc=$? |
| 130 | _MUSE_LAST_DIRTY_RC=$rc |
| 131 | if (( rc == 124 )); then |
| 132 | # Timeout — leave previous dirty state in place rather than lying. |
| 133 | return |
| 134 | fi |
| 135 | while IFS= read -r line; do |
| 136 | [[ "$line" == "##"* || -z "$line" ]] && continue |
| 137 | (( count++ )) |
| 138 | done <<< "$output" |
| 139 | MUSE_DIRTY=$(( count > 0 ? 1 : 0 )) |
| 140 | MUSE_DIRTY_COUNT=$count |
| 141 | } |
| 142 | |
| 143 | # ── §2 Cache management ────────────────────────────────────────────────────── |
| 144 | |
| 145 | # Full refresh: head + domain + dirty. Called on directory change and on load. |
| 146 | # One muse subprocess (status --porcelain) runs every time — same model as the |
| 147 | # git plugin. The timeout in _muse_check_dirty keeps it bounded. |
| 148 | function _muse_refresh() { |
| 149 | if ! _muse_find_root; then |
| 150 | MUSE_DOMAIN="midi"; MUSE_BRANCH=""; MUSE_DIRTY=0; MUSE_DIRTY_COUNT=0 |
| 151 | return 1 |
| 152 | fi |
| 153 | _muse_parse_head |
| 154 | _muse_parse_domain |
| 155 | _muse_check_dirty |
| 156 | } |
| 157 | |
| 158 | # Post-command refresh: same as _muse_refresh but resets the command flag. |
| 159 | function _muse_refresh_full() { |
| 160 | _muse_refresh || return |
| 161 | _MUSE_CMD_RAN=0 |
| 162 | } |
| 163 | |
| 164 | # ── §3 ZSH hooks ───────────────────────────────────────────────────────────── |
| 165 | |
| 166 | # On directory change: refresh head and domain; clear dirty (stale after cd). |
| 167 | # Pre-clear MUSE_REPO_ROOT so any silent failure in _muse_refresh leaves the |
| 168 | # prompt blank rather than showing stale data from the previous directory. |
| 169 | function _muse_hook_chpwd() { |
| 170 | MUSE_REPO_ROOT=""; MUSE_BRANCH=""; MUSE_DIRTY=0; MUSE_DIRTY_COUNT=0 |
| 171 | _muse_refresh 2>/dev/null |
| 172 | } |
| 173 | chpwd_functions+=(_muse_hook_chpwd) |
| 174 | |
| 175 | # Before a command: flag when the user runs muse so we refresh after. |
| 176 | function _muse_hook_preexec() { |
| 177 | [[ "${${(z)1}[1]}" == "muse" ]] && _MUSE_CMD_RAN=1 |
| 178 | } |
| 179 | preexec_functions+=(_muse_hook_preexec) |
| 180 | |
| 181 | # Before the prompt: full refresh only when a muse command just ran. |
| 182 | function _muse_hook_precmd() { |
| 183 | (( _MUSE_CMD_RAN )) && _muse_refresh_full 2>/dev/null |
| 184 | } |
| 185 | precmd_functions+=(_muse_hook_precmd) |
| 186 | |
| 187 | # ── §4 Prompt ──────────────────────────────────────────────────────────────── |
| 188 | |
| 189 | # Primary prompt segment. Example usage in ~/.zshrc: |
| 190 | # PROMPT='%~ $(muse_prompt_info) %# ' |
| 191 | # Emits nothing when not inside a muse repo. |
| 192 | # |
| 193 | # Clean: muse:(code:main) — domain:branch in magenta |
| 194 | # Dirty: muse:(code:main) — domain:branch in yellow |
| 195 | # |
| 196 | # The color of the domain:branch text is the only dirty signal — no extra |
| 197 | # symbol. Yellow means "uncommitted changes exist"; magenta means clean. |
| 198 | function muse_prompt_info() { |
| 199 | [[ -z "$MUSE_REPO_ROOT" ]] && return |
| 200 | |
| 201 | # Escape % so ZSH does not treat branch-name content as prompt directives. |
| 202 | local branch="${MUSE_BRANCH//\%/%%}" |
| 203 | local domain="${MUSE_DOMAIN//\%/%%}" |
| 204 | |
| 205 | # Yellow interior when dirty; magenta when clean. |
| 206 | local inner_color="%F{magenta}" |
| 207 | (( MUSE_DIRTY )) && inner_color="%F{yellow}" |
| 208 | |
| 209 | # Format: muse:(domain:branch) — mirrors git:(branch) but adds the domain. |
| 210 | # Set MUSE_PROMPT_ICONS=1 in ~/.zshrc to prepend a domain icon. |
| 211 | if [[ "$MUSE_PROMPT_ICONS" == "1" ]]; then |
| 212 | local icon="${MUSE_DOMAIN_ICONS[$MUSE_DOMAIN]:-${MUSE_DOMAIN_ICONS[_default]}}" |
| 213 | echo -n "%F{cyan}${icon} muse:(${inner_color}${domain}:${branch}%F{cyan})%f" |
| 214 | else |
| 215 | echo -n "%F{cyan}muse:(${inner_color}${domain}:${branch}%F{cyan})%f" |
| 216 | fi |
| 217 | } |
| 218 | |
| 219 | # ── §5 Debug ───────────────────────────────────────────────────────────────── |
| 220 | |
| 221 | # Print current plugin state. Run when the prompt looks wrong. |
| 222 | # muse_debug |
| 223 | function muse_debug() { |
| 224 | print "MUSE_REPO_ROOT = ${MUSE_REPO_ROOT:-(not set)}" |
| 225 | print "MUSE_BRANCH = ${MUSE_BRANCH:-(not set)}" |
| 226 | print "MUSE_DOMAIN = ${MUSE_DOMAIN:-(not set)}" |
| 227 | print "MUSE_DIRTY = $MUSE_DIRTY (count: $MUSE_DIRTY_COUNT)" |
| 228 | print "MUSE_DIRTY_TIMEOUT = ${MUSE_DIRTY_TIMEOUT}s" |
| 229 | print "_MUSE_LAST_DIRTY_RC = $_MUSE_LAST_DIRTY_RC (124 = timed out)" |
| 230 | print "_MUSE_CMD_RAN = $_MUSE_CMD_RAN" |
| 231 | print "PWD = $PWD" |
| 232 | if [[ -n "$MUSE_REPO_ROOT" ]]; then |
| 233 | print "repo.json = $MUSE_REPO_ROOT/.muse/repo.json" |
| 234 | print "HEAD = $(< "$MUSE_REPO_ROOT/.muse/HEAD" 2>/dev/null || print '(missing)')" |
| 235 | print "--- muse status --porcelain (live, timed) ---" |
| 236 | time (cd -- "$MUSE_REPO_ROOT" && muse status --porcelain 2>&1) |
| 237 | print "--------------------------------------------" |
| 238 | fi |
| 239 | } |
| 240 | |
| 241 | # ── §6 Aliases ─────────────────────────────────────────────────────────────── |
| 242 | alias mst='muse status' |
| 243 | alias msts='muse status --short' |
| 244 | alias mcm='muse commit -m' |
| 245 | alias mco='muse checkout' |
| 246 | alias mlg='muse log' |
| 247 | alias mlgo='muse log --oneline' |
| 248 | alias mlgg='muse log --graph' |
| 249 | alias mdf='muse diff' |
| 250 | alias mdfst='muse diff --stat' |
| 251 | alias mbr='muse branch' |
| 252 | alias mtg='muse tag' |
| 253 | alias mfh='muse fetch' |
| 254 | alias mpull='muse pull' |
| 255 | alias mpush='muse push' |
| 256 | alias mrm='muse remote' |
| 257 | |
| 258 | # ── §7 Completion ──────────────────────────────────────────────────────────── |
| 259 | if [[ -f "${0:A:h}/_muse" ]]; then |
| 260 | fpath=("${0:A:h}" $fpath) |
| 261 | autoload -Uz compinit |
| 262 | compdef _muse muse 2>/dev/null |
| 263 | fi |
| 264 | |
| 265 | # ── §8 Init ────────────────────────────────────────────────────────────────── |
| 266 | # Warm head + domain on load so the first prompt is not blank. |
| 267 | _muse_refresh 2>/dev/null |