gabriel / muse public
omzsh-plugin.md markdown
218 lines 6.1 KB
674a6394 feat(zsh-plugin): yellow branch name is the dirty signal, no extra symbol Gabriel Cardona <gabriel@tellurstori.com> 3d ago
1 # Oh My ZSH Plugin — Reference
2
3 Minimal, secure ZSH integration for Muse. Provides a prompt segment, core
4 aliases, and tab completion. Nothing runs automatically beyond what is needed to
5 keep the prompt accurate.
6
7 ---
8
9 ## Files
10
11 | File | Purpose |
12 |------|---------|
13 | `tools/omzsh-plugin/muse.plugin.zsh` | Main plugin (~230 lines) |
14 | `tools/omzsh-plugin/_muse` | ZSH completion function |
15 | `tools/install-omzsh-plugin.sh` | Symlink installer |
16
17 ---
18
19 ## Prompt segment
20
21 ```zsh
22 # In ~/.zshrc
23 PROMPT='%~ $(muse_prompt_info) %# '
24 ```
25
26 `muse_prompt_info` emits nothing outside a Muse repo. Inside one it emits:
27
28 ```
29 %F{cyan}muse:(%F{<color>}<domain>:<branch>%F{cyan})%f
30 ```
31
32 The inner color is the only dirty signal — no extra symbol or count:
33
34 - **magenta** — working tree is clean
35 - **yellow** — uncommitted changes exist
36
37 This mirrors the Git plugin's `git:(branch)` format, extended with the domain
38 name — the key differentiator from a single-domain VCS.
39
40 ### Examples
41
42 | Output | Inner color | Meaning |
43 |--------|-------------|---------|
44 | `muse:(midi:main)` | magenta | clean |
45 | `muse:(midi:main)` | yellow | uncommitted changes |
46 | `muse:(midi:a1b2c3d4)` | magenta | detached HEAD, clean |
47 | `muse:(midi:a1b2c3d4)` | yellow | detached HEAD, uncommitted changes |
48
49 The yellow state only appears after a `muse` command runs in the current shell.
50
51 ### Domain icons (optional)
52
53 Icons are off by default. Set `MUSE_PROMPT_ICONS=1` to prepend one:
54
55 | Domain | Icon | Config key |
56 |--------|------|-----------|
57 | `midi` | `♪` | `MUSE_DOMAIN_ICONS[midi]` |
58 | `code` | `⌥` | `MUSE_DOMAIN_ICONS[code]` |
59 | `bitcoin` | `₿` | `MUSE_DOMAIN_ICONS[bitcoin]` |
60 | `scaffold` | `⬡` | `MUSE_DOMAIN_ICONS[scaffold]` |
61 | (unknown) | `◈` | `MUSE_DOMAIN_ICONS[_default]` |
62
63 With icons on, the prompt becomes `♪ muse:(midi:main)`.
64
65 ---
66
67 ## Environment variables
68
69 ### Configuration (set before `plugins=(… muse …)`)
70
71 | Variable | Default | Meaning |
72 |----------|---------|---------|
73 | `MUSE_PROMPT_ICONS` | `0` | `1` prepends a domain icon before `muse:(…)` |
74 | `MUSE_DIRTY_TIMEOUT` | `1` | Seconds before dirty check aborts |
75
76 ### State (read-only, exported by plugin)
77
78 | Variable | Type | Meaning |
79 |----------|------|---------|
80 | `MUSE_REPO_ROOT` | string | Absolute path to repo root, or `""` |
81 | `MUSE_DOMAIN` | string | Active domain name |
82 | `MUSE_BRANCH` | string | Branch name, short SHA, or `?` |
83 | `MUSE_DIRTY` | integer | `1` if working tree has changes |
84 | `MUSE_DIRTY_COUNT` | integer | Number of changed paths |
85
86 ---
87
88 ## Hooks
89
90 | Hook | When it fires | What it does |
91 |------|--------------|--------------|
92 | `chpwd` | On `cd` | Re-finds repo root, re-reads HEAD and domain; clears dirty state |
93 | `preexec` | Before any command | Sets `_MUSE_CMD_RAN=1` when command is `muse` |
94 | `precmd` | Before prompt | Runs full refresh (including dirty check) only if `_MUSE_CMD_RAN=1` |
95
96 ---
97
98 ## Aliases
99
100 | Alias | Expands to |
101 |-------|-----------|
102 | `mst` | `muse status` |
103 | `msts` | `muse status --short` |
104 | `mcm` | `muse commit -m` |
105 | `mco` | `muse checkout` |
106 | `mlg` | `muse log` |
107 | `mlgo` | `muse log --oneline` |
108 | `mlgg` | `muse log --graph` |
109 | `mdf` | `muse diff` |
110 | `mdfst` | `muse diff --stat` |
111 | `mbr` | `muse branch` |
112 | `mtg` | `muse tag` |
113 | `mfh` | `muse fetch` |
114 | `mpull` | `muse pull` |
115 | `mpush` | `muse push` |
116 | `mrm` | `muse remote` |
117
118 ---
119
120 ## Completion
121
122 The `_muse` completion function handles:
123
124 - Top-level command names with descriptions.
125 - Branch names for `checkout`, `merge`, `cherry-pick`, `branch`, `reset`, `revert`, `diff`, `show`, `blame`.
126 - Remote names for `push`, `pull`, `fetch`.
127 - Tag names for `tag`.
128 - Config key suggestions for `config`.
129 - Subcommand names for `stash`, `remote`, `plumbing`, `commit` flags.
130
131 All branch/tag/remote lookups use ZSH glob patterns against `.muse/refs/` and
132 `.muse/remotes/` — no subprocess, no `ls`, instant.
133
134 ---
135
136 ## Performance model
137
138 | Trigger | Subprocesses | What runs |
139 |---------|-------------|-----------|
140 | Prompt render | 0 | Reads cached shell vars only |
141 | `cd` into repo | 1 (`python3`) | HEAD (ZSH read) + domain (python3) |
142 | `cd` outside repo | 0 | Clears vars only |
143 | After `muse` command | 1 (`muse status`) | Full refresh + dirty check |
144 | Tab completion | 0 | ZSH glob reads `.muse/refs/` |
145
146 ---
147
148 ## Security model
149
150 ### Branch name injection
151
152 `.muse/HEAD` is read with a pure ZSH `$(<file)` — no subprocess. Muse writes
153 HEAD in one of two self-describing forms (set by `muse/core/store.py`):
154
155 ```
156 ref: refs/heads/<branch> — on a branch
157 commit: <sha256> — detached HEAD
158 ```
159
160 The branch name is validated with `[[ "$branch" =~ '^[[:alnum:]/_.-]+$' ]]`.
161 Any name containing characters outside this set (including `%`, `$`, backticks,
162 quotes) is replaced with `?`. Valid names are additionally `%`-escaped
163 (`${branch//\%/%%}`) before insertion into the prompt string, so ZSH never
164 interprets them as prompt directives.
165
166 ### Domain injection
167
168 The domain value from `.muse/repo.json` is extracted by `python3` and validated
169 with `safe.isalnum() and 1 <= len(v) <= 32` before printing. The path to
170 `repo.json` is passed via the `MUSE_REPO_JSON` environment variable — never
171 interpolated into a `-c` string — so a path containing single quotes, spaces,
172 or special characters is handled safely.
173
174 ### Path injection
175
176 `cd -- "$MUSE_REPO_ROOT"` uses `--` so the path cannot be interpreted as a
177 flag. `timeout -- ...` follows the same pattern.
178
179 ### No `eval`
180
181 No user-supplied data is ever passed to `eval`.
182
183 ### Completion safety
184
185 The completion function uses ZSH glob expansion (`${refs_dir}/*(N:t)`) instead
186 of `$(ls ...)` to enumerate branches. This avoids word-splitting on filenames
187 that contain spaces, and prevents `ls` output from being treated as shell tokens.
188
189 ---
190
191 ## Installation
192
193 ```bash
194 bash tools/install-omzsh-plugin.sh
195 ```
196
197 The script creates a symlink from `~/.oh-my-zsh/custom/plugins/muse/` to
198 `tools/omzsh-plugin/`. Because it is a symlink, pulling new commits to the Muse
199 repo automatically updates the plugin.
200
201 Add to `~/.zshrc`:
202
203 ```zsh
204 plugins=(git muse)
205 ```
206
207 Then add the prompt segment:
208
209 ```zsh
210 # Append to your existing PROMPT, or set a new one:
211 PROMPT+='$(muse_prompt_info) '
212 ```
213
214 Then reload:
215
216 ```zsh
217 exec zsh
218 ```