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:=1} |
| 31 | : ${MUSE_DIRTY_TIMEOUT:=1} |
| 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 .muse/. Sets MUSE_REPO_ROOT. Pure ZSH. |
| 54 | function _muse_find_root() { |
| 55 | local dir="$PWD" |
| 56 | while [[ "$dir" != "/" ]]; do |
| 57 | if [[ -d "$dir/.muse" ]]; then |
| 58 | MUSE_REPO_ROOT="$dir" |
| 59 | return 0 |
| 60 | fi |
| 61 | dir="${dir:h}" |
| 62 | done |
| 63 | MUSE_REPO_ROOT="" |
| 64 | return 1 |
| 65 | } |
| 66 | |
| 67 | # Read branch from .muse/HEAD without forking. Validates before storing. |
| 68 | # Branch names are restricted to [a-zA-Z0-9/_.-] to prevent prompt injection. |
| 69 | # |
| 70 | # Muse HEAD format (canonical, written by muse/core/store.py): |
| 71 | # ref: refs/heads/<branch> — on a branch (symbolic ref) |
| 72 | # commit: <sha256> — detached HEAD (direct commit reference) |
| 73 | function _muse_parse_head() { |
| 74 | local head_file="$MUSE_REPO_ROOT/.muse/HEAD" |
| 75 | if [[ ! -f "$head_file" ]]; then |
| 76 | MUSE_BRANCH=""; return 1 |
| 77 | fi |
| 78 | local raw |
| 79 | raw=$(<"$head_file") |
| 80 | if [[ "$raw" == "refs/heads/"* ]]; then |
| 81 | local branch="${raw#refs/heads/}" |
| 82 | # Reject anything that could inject prompt escapes or path components. |
| 83 | if [[ "$branch" =~ '^[[:alnum:]/_.-]+$' ]]; then |
| 84 | MUSE_BRANCH="$branch" |
| 85 | else |
| 86 | MUSE_BRANCH="?" |
| 87 | fi |
| 88 | elif [[ "$raw" == "commit: "* ]]; then |
| 89 | local sha="${raw#commit: }" |
| 90 | MUSE_BRANCH="${sha:0:8}" # detached HEAD — show short SHA |
| 91 | else |
| 92 | MUSE_BRANCH="?" |
| 93 | fi |
| 94 | } |
| 95 | |
| 96 | # Read domain from .muse/repo.json. One python3 call; path via env var only. |
| 97 | function _muse_parse_domain() { |
| 98 | local repo_json="$MUSE_REPO_ROOT/.muse/repo.json" |
| 99 | if [[ ! -f "$repo_json" ]]; then |
| 100 | MUSE_DOMAIN="midi"; return |
| 101 | fi |
| 102 | MUSE_DOMAIN=$(MUSE_REPO_JSON="$repo_json" python3 <<'PYEOF' 2>/dev/null |
| 103 | import json, os |
| 104 | try: |
| 105 | d = json.load(open(os.environ['MUSE_REPO_JSON'])) |
| 106 | v = str(d.get('domain', 'midi')) |
| 107 | # Accept only safe domain names: alphanumeric plus hyphens/underscores, |
| 108 | # max 32 chars. Anything else falls back to 'midi'. |
| 109 | safe = v.replace('-', '').replace('_', '') |
| 110 | print(v if (safe.isalnum() and 1 <= len(v) <= 32) else 'midi') |
| 111 | except Exception: |
| 112 | print('midi') |
| 113 | PYEOF |
| 114 | ) |
| 115 | : ${MUSE_DOMAIN:=midi} |
| 116 | } |
| 117 | |
| 118 | # Check dirty state. Runs with timeout; only called after a muse command. |
| 119 | function _muse_check_dirty() { |
| 120 | local output rc count=0 |
| 121 | output=$(cd -- "$MUSE_REPO_ROOT" && \ |
| 122 | timeout -- "${MUSE_DIRTY_TIMEOUT}" muse status --porcelain 2>/dev/null) |
| 123 | rc=$? |
| 124 | if (( rc == 124 )); then |
| 125 | # Timeout — leave previous dirty state in place rather than lying. |
| 126 | return |
| 127 | fi |
| 128 | while IFS= read -r line; do |
| 129 | [[ "$line" == "##"* || -z "$line" ]] && continue |
| 130 | (( count++ )) |
| 131 | done <<< "$output" |
| 132 | MUSE_DIRTY=$(( count > 0 ? 1 : 0 )) |
| 133 | MUSE_DIRTY_COUNT=$count |
| 134 | } |
| 135 | |
| 136 | # ── §2 Cache management ────────────────────────────────────────────────────── |
| 137 | |
| 138 | # Lightweight refresh: head + domain only. Called on directory change. |
| 139 | function _muse_refresh() { |
| 140 | if ! _muse_find_root; then |
| 141 | MUSE_DOMAIN="midi"; MUSE_BRANCH=""; MUSE_DIRTY=0; MUSE_DIRTY_COUNT=0 |
| 142 | return 1 |
| 143 | fi |
| 144 | _muse_parse_head |
| 145 | _muse_parse_domain |
| 146 | } |
| 147 | |
| 148 | # Full refresh: head + domain + dirty. Called after a muse command. |
| 149 | function _muse_refresh_full() { |
| 150 | _muse_refresh || return |
| 151 | _muse_check_dirty |
| 152 | _MUSE_CMD_RAN=0 |
| 153 | } |
| 154 | |
| 155 | # ── §3 ZSH hooks ───────────────────────────────────────────────────────────── |
| 156 | |
| 157 | # On directory change: refresh head and domain; clear dirty (stale after cd). |
| 158 | function _muse_hook_chpwd() { |
| 159 | MUSE_DIRTY=0; MUSE_DIRTY_COUNT=0 |
| 160 | _muse_refresh 2>/dev/null |
| 161 | } |
| 162 | chpwd_functions+=(_muse_hook_chpwd) |
| 163 | |
| 164 | # Before a command: flag when the user runs muse so we refresh after. |
| 165 | function _muse_hook_preexec() { |
| 166 | [[ "${${(z)1}[1]}" == "muse" ]] && _MUSE_CMD_RAN=1 |
| 167 | } |
| 168 | preexec_functions+=(_muse_hook_preexec) |
| 169 | |
| 170 | # Before the prompt: full refresh only when a muse command just ran. |
| 171 | function _muse_hook_precmd() { |
| 172 | (( _MUSE_CMD_RAN )) && _muse_refresh_full 2>/dev/null |
| 173 | } |
| 174 | precmd_functions+=(_muse_hook_precmd) |
| 175 | |
| 176 | # ── §4 Prompt ──────────────────────────────────────────────────────────────── |
| 177 | |
| 178 | # Primary prompt segment. Example usage in ~/.zshrc: |
| 179 | # PROMPT='%~ $(muse_prompt_info) %# ' |
| 180 | # Emits nothing when not inside a muse repo. |
| 181 | function muse_prompt_info() { |
| 182 | [[ -z "$MUSE_REPO_ROOT" ]] && return |
| 183 | |
| 184 | local icon="${MUSE_DOMAIN_ICONS[$MUSE_DOMAIN]:-${MUSE_DOMAIN_ICONS[_default]}}" |
| 185 | [[ "$MUSE_PROMPT_ICONS" == "0" ]] && icon="[$MUSE_DOMAIN]" |
| 186 | |
| 187 | # Escape % so ZSH does not treat branch-name content as prompt directives. |
| 188 | local branch="${MUSE_BRANCH//\%/%%}" |
| 189 | |
| 190 | local dirty="" |
| 191 | (( MUSE_DIRTY )) && dirty=" %F{red}✗ ${MUSE_DIRTY_COUNT}%f" |
| 192 | |
| 193 | echo -n "%F{magenta}${icon} ${branch}%f${dirty}" |
| 194 | } |
| 195 | |
| 196 | # ── §5 Aliases ─────────────────────────────────────────────────────────────── |
| 197 | alias mst='muse status' |
| 198 | alias msts='muse status --short' |
| 199 | alias mcm='muse commit -m' |
| 200 | alias mco='muse checkout' |
| 201 | alias mlg='muse log' |
| 202 | alias mlgo='muse log --oneline' |
| 203 | alias mlgg='muse log --graph' |
| 204 | alias mdf='muse diff' |
| 205 | alias mdfst='muse diff --stat' |
| 206 | alias mbr='muse branch' |
| 207 | alias mtg='muse tag' |
| 208 | alias mfh='muse fetch' |
| 209 | alias mpull='muse pull' |
| 210 | alias mpush='muse push' |
| 211 | alias mrm='muse remote' |
| 212 | |
| 213 | # ── §6 Completion ──────────────────────────────────────────────────────────── |
| 214 | if [[ -f "${0:A:h}/_muse" ]]; then |
| 215 | fpath=("${0:A:h}" $fpath) |
| 216 | autoload -Uz compinit |
| 217 | compdef _muse muse 2>/dev/null |
| 218 | fi |
| 219 | |
| 220 | # ── Init ────────────────────────────────────────────────────────────────────── |
| 221 | # Warm head + domain on load so the first prompt is not blank. |
| 222 | _muse_refresh 2>/dev/null |