acstack 0.48.5 → 0.49.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/assets/terminal-workspace/helpers/acstack-bridge +185 -0
  3. package/assets/terminal-workspace/helpers/acstack-clip +88 -0
  4. package/assets/terminal-workspace/helpers/acstack-dash +25 -0
  5. package/assets/terminal-workspace/helpers/acstack-db +86 -0
  6. package/assets/terminal-workspace/helpers/acstack-diff +268 -0
  7. package/assets/terminal-workspace/helpers/acstack-edit +25 -0
  8. package/assets/terminal-workspace/helpers/acstack-gh +47 -0
  9. package/assets/terminal-workspace/helpers/acstack-herdr-renumber +93 -0
  10. package/assets/terminal-workspace/helpers/acstack-herdr-tab +49 -0
  11. package/assets/terminal-workspace/helpers/acstack-md +401 -0
  12. package/assets/terminal-workspace/helpers/acstack-md-lib +219 -0
  13. package/assets/terminal-workspace/helpers/acstack-open +217 -0
  14. package/assets/terminal-workspace/helpers/acstack-popup +13 -0
  15. package/assets/terminal-workspace/helpers/acstack-view +33 -0
  16. package/assets/terminal-workspace/helpers/xclip +31 -0
  17. package/assets/terminal-workspace/lib/apply-ghdash.sh +78 -0
  18. package/assets/terminal-workspace/lib/apply-herdr-numbers.sh +49 -0
  19. package/assets/terminal-workspace/lib/apply-herdr.sh +308 -0
  20. package/assets/terminal-workspace/lib/apply-tools.sh +293 -0
  21. package/assets/terminal-workspace/lib/apply-tuicr.sh +133 -0
  22. package/assets/terminal-workspace/lib/apply-yazi.sh +389 -0
  23. package/assets/terminal-workspace/lib/commands.sh +296 -0
  24. package/assets/terminal-workspace/lib/core.sh +184 -0
  25. package/assets/terminal-workspace/lib/helper-install.sh +35 -0
  26. package/assets/terminal-workspace/lib/pi-packages.sh +331 -0
  27. package/assets/terminal-workspace/lib/pi-state.sh +479 -0
  28. package/assets/terminal-workspace/lib/versions.sh +254 -0
  29. package/assets/terminal-workspace/setup.sh +24 -4637
  30. package/cli/dist/index.js +8 -4
  31. package/cli/dist/index.js.map +2 -2
  32. package/cli/package.json +1 -1
  33. package/package.json +1 -1
  34. package/skills/archive/SKILL.md +3 -0
  35. package/skills/grilling/SKILL.md +2 -1
  36. package/skills/handoff/SKILL.md +14 -14
  37. package/skills/handoff/launch-herdr.md +15 -4
  38. package/skills/implement/SKILL.md +19 -3
  39. package/skills/map/SKILL.md +24 -24
  40. package/skills/map/alignment.md +38 -0
@@ -7,10 +7,19 @@
7
7
  # setup.sh disable <herdr|delta|diffnav|gh_dash|harlequin|lazygit|neovim|pi|tuicr>
8
8
  # setup.sh uninstall remove every managed block, keep the binaries
9
9
  #
10
+ # Layout map:
11
+ # config readers and shared helpers
12
+ # installers and embedded acstack-* helper scripts
13
+ # per-tool config writers
14
+ # pi state, pi package, and pi config helpers
15
+ # version, status, apply, upgrade, disable, and uninstall commands
16
+ #
10
17
  # Idempotent. Only ever edits between managed markers, so hand-written config
11
18
  # around it survives.
12
19
  set -uo pipefail
13
20
 
21
+ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "${BASH_SOURCE[0]:-$0}")" && pwd)"
22
+
14
23
  CFG="${ACSTACK_TW_CONFIG:-$HOME/.config/acstack/terminal-workspace.yaml}"
15
24
  BIN="${ACSTACK_TW_BIN:-$HOME/.local/bin}"
16
25
  # Everything installs into $BIN, and the config steps then ask `have <tool>`
@@ -37,6 +46,7 @@ PI_SUBAGENT_CFG="$PI_DIR/extensions/subagent/config.json"
37
46
  # pi's override, so look where it writes.
38
47
  PI_RETRO_EXT="${PI_HOME:-$HOME/.pi}/agent/extensions/agentic-retro.ts"
39
48
  PI_ACSTACK="npm:acstack"
49
+ PI_ACSTACK_GIT="git:github.com/ac8318740/spechub"
40
50
  PI_BILLION="npm:billion-context"
41
51
  BILLION_CONFIG="$HOME/.config/billion-context/billion-context.json"
42
52
  PI_SLASH_ANYWHERE="npm:pi-slash-anywhere"
@@ -50,4644 +60,21 @@ PI_LENS_CFG="$HOME/.pi-lens/config.json"
50
60
  PI_MD_BEGIN="<!-- >>> acstack terminal-workspace >>> -->"
51
61
  PI_MD_END="<!-- <<< acstack terminal-workspace <<< -->"
52
62
  PI_DELEGATION="When an ACStack skill or the ACStack orchestrator says to launch a subagent, you may delegate to it."
53
- ACTION="${1:-status}"
54
-
55
- have() { command -v "$1" >/dev/null 2>&1; }
56
-
57
- require_yaml() {
58
- python3 -c 'import yaml' 2>/dev/null && return 0
59
- echo "PyYAML is required to read $CFG" >&2
60
- echo "Install it (pip install --user pyyaml) and run again. Refusing to" >&2
61
- echo "continue, because without it every setting falls back to a default" >&2
62
- echo "and your config is silently ignored." >&2
63
- exit 1
64
- }
65
-
66
- arch_supported() {
67
- case "$(uname -m)" in
68
- x86_64|amd64) return 0 ;;
69
- *) say "prebuilt binaries are x86_64 only, and this is $(uname -m)"
70
- say "install herdr, delta, diffnav, and lazygit yourself, then run apply again"
71
- return 1 ;;
72
- esac
73
- }
74
- say() { printf ' %s\n' "$*"; }
75
-
76
- py() { python3 - "$@"; }
77
-
78
- # Reads one dotted path. require_yaml runs first, so a fallback here means
79
- # the key is absent, never that yaml failed to import.
80
- cfg_get() { # cfg_get <dotted.path> <default>
81
- ACSTACK_CFG="$CFG" py "$1" "${2:-}" <<'PY'
82
- import os, sys
83
- import yaml
84
- p = os.environ["ACSTACK_CFG"]
85
- if not os.path.isfile(p):
86
- print(sys.argv[2]); raise SystemExit
87
- d = yaml.safe_load(open(p)) or {}
88
- for k in sys.argv[1].split("."):
89
- d = d.get(k) if isinstance(d, dict) else None
90
- if d is None: break
91
- if isinstance(d, bool): d = "true" if d else "false"
92
- print(d if d is not None else sys.argv[2])
93
- PY
94
- }
95
-
96
- # neovim and pi write into a config the user built, so they start off. Every
97
- # other component owns its files outright and starts on.
98
- enabled_default() { # enabled_default <component>
99
- case "$1" in neovim|pi) echo false ;; *) echo true ;; esac
100
- }
101
-
102
- # The example config that shipped beside this script. Both `outdated` and the
103
- # stamp read it, so resolve it once.
104
- EXAMPLE="$(dirname "$0")/config.example.yaml"
105
- STAMP="$(dirname "$CFG")/terminal-workspace.applied"
106
- # What the pi component changed, so disable undoes that and nothing else. A
107
- # package or a setting the user already had is theirs, and stays.
108
- PI_OWNED="$(dirname "$CFG")/terminal-workspace.pi.json"
109
-
110
- # Reads a yaml list into one item per line. cfg_get prints a python list repr
111
- # for a list value, which no caller can use.
112
- cfg_get_list() { # cfg_get_list <dotted.path> <space-separated default>
113
- ACSTACK_CFG="$CFG" py "$1" "${2:-}" <<'PY'
114
- import os, sys
115
- import yaml
116
- p = os.environ["ACSTACK_CFG"]
117
- d = (yaml.safe_load(open(p)) or {}) if os.path.isfile(p) else {}
118
- for k in sys.argv[1].split("."):
119
- d = d.get(k) if isinstance(d, dict) else None
120
- if d is None: break
121
- print("\n".join(str(x) for x in d) if isinstance(d, list) else sys.argv[2].replace(" ", "\n"))
122
- PY
123
- }
124
-
125
- # What `apply` had to offer, recorded so a later run can tell what arrived
126
- # since. Without it, a component added by an upgrade is invisible: cfg_get
127
- # returns its default, apply installs it, and nobody is ever told it is there.
128
- write_stamp() {
129
- [ -f "$EXAMPLE" ] || return 0
130
- mkdir -p "$(dirname "$STAMP")"
131
- { echo "# written by acstack terminal-workspace apply, do not edit"
132
- py "$EXAMPLE" <<'PY'
133
- import sys
134
- import yaml
135
- for k in (yaml.safe_load(open(sys.argv[1])) or {}):
136
- print(k)
137
- PY
138
- } > "$STAMP"
139
- }
140
-
141
- install_binary() { # install_binary <name> <repo> <asset-match>
142
- local name="$1" repo="$2" match="$3"
143
- # `upgrade` reinstalls over a binary that is already here. `apply` never
144
- # does: it installs what is missing and leaves what is not.
145
- if [ "${INSTALL_FORCE:-0}" != "1" ] && have "$name"; then
146
- say "$name already installed"; return 0
147
- fi
148
- local url json
149
- # py reads its script from stdin, so the release JSON has to arrive by
150
- # environment rather than by pipe: a pipe here is silently swallowed by
151
- # the heredoc and python then reads an empty stdin.
152
- json=$(curl -sSf "https://api.github.com/repos/$repo/releases/latest" 2>/dev/null)
153
- [ -z "$json" ] && { say "$name: could not reach the GitHub release API"; return 1; }
154
- url=$(ACSTACK_JSON="$json" py "$match" <<'PY'
155
- import json, os, sys
156
- try:
157
- d = json.loads(os.environ["ACSTACK_JSON"])
158
- except ValueError:
159
- raise SystemExit
160
- for a in d.get("assets", []):
161
- # yazi publishes .zip only; delta and glow publish .tar.gz. Take either.
162
- if sys.argv[1] in a["name"] and a["name"].endswith((".tar.gz", ".tgz", ".zip")):
163
- print(a["browser_download_url"]); break
164
- PY
165
- )
166
- [ -z "$url" ] && { say "$name: no matching release asset, install manually"; return 1; }
167
- # Explicit cleanup rather than `trap ... RETURN`: a RETURN trap stays
168
- # registered for every later function return in this shell, where $tmp is
169
- # gone and `set -u` then aborts the run.
170
- local tmp found ar; tmp=$(mktemp -d)
171
- case "$url" in *.zip) ar="$tmp/a.zip" ;; *) ar="$tmp/a.tgz" ;; esac
172
- if ! curl -sL "$url" -o "$ar"; then
173
- rm -rf "$tmp"; say "$name: download failed"; return 1
174
- fi
175
- case "$ar" in
176
- *.zip) have unzip || { rm -rf "$tmp"; say "$name: needs unzip"; return 1; }
177
- unzip -qo "$ar" -d "$tmp" || { rm -rf "$tmp"; say "$name: unzip failed"; return 1; } ;;
178
- *) tar xzf "$ar" -C "$tmp" || { rm -rf "$tmp"; say "$name: untar failed"; return 1; } ;;
179
- esac
180
- found=$(find "$tmp" -type f -name "$name" | head -1)
181
- if [ -z "$found" ]; then
182
- rm -rf "$tmp"; say "$name: binary not found in archive"; return 1
183
- fi
184
- mkdir -p "$BIN"; cp "$found" "$BIN/$name"; chmod +x "$BIN/$name"
185
- rm -rf "$tmp"
186
- say "$name installed to $BIN/$name"
187
- }
188
-
189
- install_plain_github_binary() { # install_plain_github_binary <name> <repo> <asset-match>
190
- local name="$1" repo="$2" match="$3"
191
- if [ "${INSTALL_FORCE:-0}" != "1" ] && have "$name"; then
192
- say "$name already installed"; return 0
193
- fi
194
- local json url tmp
195
- json=$(curl -sSf "https://api.github.com/repos/$repo/releases/latest" 2>/dev/null)
196
- [ -z "$json" ] && { say "$name: could not reach the GitHub release API"; return 1; }
197
- url=$(MAPFLOW_JSON="$json" py "$match" <<'PY'
198
- import json, os, sys
199
- try:
200
- d = json.loads(os.environ["MAPFLOW_JSON"])
201
- except ValueError:
202
- raise SystemExit
203
- for a in d.get("assets", []):
204
- if sys.argv[1] in a["name"]:
205
- print(a["browser_download_url"]); break
206
- PY
207
- )
208
- [ -z "$url" ] && { say "$name: no matching release asset, install manually"; return 1; }
209
- tmp=$(mktemp)
210
- if ! curl -fsSL "$url" -o "$tmp"; then
211
- rm -f "$tmp"; say "$name: download failed"; return 1
212
- fi
213
- mkdir -p "$BIN"; cp "$tmp" "$BIN/$name"; chmod +x "$BIN/$name"
214
- rm -f "$tmp"
215
- say "$name installed to $BIN/$name"
216
- }
217
-
218
- install_npm_tool() { # install_npm_tool <command> <package>
219
- local command="$1" package="$2"
220
- if have "$command"; then say "$command already installed"; return 0; fi
221
- have npm || { say "$command: npm not installed, skipping"; return 0; }
222
- if npm install -g "$package" >/dev/null 2>&1; then
223
- say "$command installed with npm"
224
- else
225
- say "$command install failed, run: npm install -g $package"
226
- fi
227
- }
228
-
229
- install_uv_tool() { # install_uv_tool <command> <package>
230
- local command="$1" package="$2"
231
- if have "$command"; then say "$command already installed"; return 0; fi
232
- have uv || { say "$command: uv not installed, skipping"; return 0; }
233
- if uv tool install "$package" >/dev/null 2>&1; then
234
- say "$command installed with uv"
235
- else
236
- say "$command install failed, run: uv tool install $package"
237
- fi
238
- }
239
-
240
- write_helpers() {
241
- mkdir -p "$BIN"
242
- # Helpers this script used to write and no longer does. Upgrading otherwise
243
- # leaves them on PATH, shadowing nothing but confusing everything.
244
- rm -f "$BIN"/acstack-files "$BIN"/acstack-files-tab \
245
- "$BIN"/acstack-yazi-tab "$BIN"/acstack-tab "$BIN"/acstack-renumber
246
- cat > "$BIN/acstack-diff" <<'H'
247
- #!/usr/bin/env bash
248
- # Show a git diff in diffnav, with a banner naming what is being compared.
249
- #
250
- # acstack-diff this branch against dev, committed work only (alt+f)
251
- # acstack-diff pick choose the comparison from a menu (alt+x)
252
- # acstack-diff auto first non-empty of working tree, staged, last commit
253
- #
254
- # Every launch prepends a COMPARING block to the diff. diffnav renders whatever
255
- # precedes the first "diff --git" line, which is how git show's commit header
256
- # reaches the screen, so the block lands at the top of the content pane.
257
- #
258
- # diffnav's "o" key opens the file under the cursor with $EDITOR, in diffnav's
259
- # own pane. That pane is a herdr popup for alt+f and alt+x, and a popup cannot
260
- # host a second full-screen app, so the editor never draws there. acstack-edit
261
- # gives the file its own herdr tab instead, and both diff keys then match.
262
- set -uo pipefail
263
- if command -v acstack-edit >/dev/null 2>&1; then
264
- case "${EDITOR:-}" in *acstack-edit) EDITOR=nvim ;; esac # never nest
265
- export ACSTACK_EDIT_EDITOR="${EDITOR:-nvim}"
266
- export EDITOR=acstack-edit
267
- fi
268
- pick_checkout() {
269
- # herdr groups worktrees as <root>/<repo>/<branch-slug>, so a pane often
270
- # sits in the parent of several checkouts rather than in one.
271
- local -a repos=()
272
- while IFS= read -r d; do repos+=("$d"); done < <(
273
- find . -mindepth 1 -maxdepth 1 -type d -exec test -e '{}/.git' \; -print 2>/dev/null | sort)
274
- case ${#repos[@]} in
275
- 0) return 1 ;;
276
- 1) cd "${repos[0]}" && return 0 ;;
277
- *) echo "Not a repo, but it holds ${#repos[@]} checkouts:"; echo
278
- local i=1
279
- for d in "${repos[@]}"; do
280
- printf ' %d) %-34s %s\n' "$i" "${d#./}" "$(git -C "$d" branch --show-current 2>/dev/null)"
281
- i=$((i+1))
282
- done
283
- echo; read -rp "Which one? [1-${#repos[@]}, q to quit] " choice
284
- [[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le ${#repos[@]} ] \
285
- && cd "${repos[$((choice-1))]}" && return 0
286
- return 1 ;;
287
- esac
288
- }
289
- if ! git rev-parse --git-dir >/dev/null 2>&1; then
290
- pick_checkout || { echo "No git checkout here: $PWD"; echo "Press any key..."; read -rsn1; exit 0; }
291
- fi
292
-
293
- BRANCH=$(git branch --show-current 2>/dev/null) || BRANCH=""
294
- [ -n "$BRANCH" ] || BRANCH="HEAD (detached)"
295
-
296
- # ---- refs -----------------------------------------------------------------
297
- ref_exists() { git rev-parse --verify --quiet "$1" >/dev/null 2>&1; }
298
- first_ref() { local r; for r in "$@"; do ref_exists "$r" && { echo "$r"; return 0; }; done; return 1; }
299
- dev_ref() { first_ref origin/dev dev; } # the server's dev wins over a stale local one
300
- # default_compare falls back to the default branch only where no dev branch exists
301
- default_ref() {
302
- local d; d=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
303
- [ -n "$d" ] && { echo "$d"; return 0; }
304
- first_ref origin/main main origin/master master
305
- }
306
- tip() { # tip <ref> -> "1b298bd . 4 hours ago . Merge pull request ..."
307
- # kept short: diffnav's content pane is narrower than the popup, so a long
308
- # line wraps and the banner stops looking like a banner.
309
- git log -1 --format='%h %ar %s' "$1" 2>/dev/null | cut -c1-70
310
- }
311
-
312
- # ---- what diffnav cannot draw ---------------------------------------------
313
- # diffnav hands each file to delta, and delta dies on SIGABRT when one line
314
- # runs to hundreds of kilobytes. diffnav reports the child's death as
315
- # "FATA signal: aborted (core dumped)" and quits, so one such file takes the
316
- # whole diff down with it. A committed build artifact is what does it: this
317
- # project's own cli/dist/index.js.map holds a single 982,428-character line,
318
- # and the merge commit touching it aborted diffnav in 1.7 seconds.
319
- #
320
- # So the file goes, not the diff. Any file whose patch holds a line over the
321
- # limit is dropped and named in the banner, and the rest is drawn as usual.
322
- # 20000 is far above any line a person writes and far below where delta gives
323
- # out. Raise or lower it with ACSTACK_DIFF_LINE_LIMIT.
324
- LINE_LIMIT_DEFAULT=20000
325
- LINE_LIMIT="${ACSTACK_DIFF_LINE_LIMIT:-$LINE_LIMIT_DEFAULT}"
326
- # awk compares a length against whatever this holds. Hand it "abc" and every
327
- # comparison is a string comparison that is never true, so the guard turns
328
- # itself off and the abort comes back with nothing on screen to explain it.
329
- # Hand it 0 and every file is dropped instead. Refuse both, out loud.
330
- case "$LINE_LIMIT" in
331
- ''|0|*[!0-9]*)
332
- echo "acstack-diff: ACSTACK_DIFF_LINE_LIMIT is \"$LINE_LIMIT\", which is not a" >&2
333
- echo " positive whole number. Using $LINE_LIMIT_DEFAULT." >&2
334
- LINE_LIMIT=$LINE_LIMIT_DEFAULT ;;
335
- esac
336
- drop_unrenderable() { # drop_unrenderable <skipped-file> ; diff in, diff out
337
- # One file at a time: a patch has to be held whole before its longest line
338
- # is known. Everything before the first "diff --git" is a header, so it goes
339
- # straight through. Written for gawk, mawk and busybox awk alike.
340
- awk -v lim="$LINE_LIMIT" -v skip="$1" '
341
- function flush() {
342
- if (path == "") return
343
- # A tab between the two fields. banner reads this file back and splits
344
- # on the same tab, so the two have to move together.
345
- if (long) printf "%s\t%d\n", path, maxlen > skip
346
- else for (i = 1; i <= n; i++) print buf[i]
347
- }
348
- /^diff --git / {
349
- flush()
350
- split("", buf); n = 0; long = 0; maxlen = 0
351
- path = $0; sub(/^diff --git a\//, "", path); sub(/ b\/.*$/, "", path)
352
- if (path == "") path = "?"
353
- }
354
- {
355
- if (path == "") { print; next }
356
- if (length($0) > maxlen) maxlen = length($0)
357
- if (length($0) > lim) long = 1
358
- buf[++n] = $0
359
- }
360
- END { flush() }'
361
- }
362
-
363
- # ---- banner ---------------------------------------------------------------
364
- banner() { # banner <source-label> <change-label> <what> <command>
365
- # No line may start with a space: diffnav strips leading whitespace, so any
366
- # indentation collapses and columns stop lining up.
367
- printf 'COMPARING %s ==> %s\n' "$1" "$2"
368
- # base and compare repeat their ref so each row ties back to the first line.
369
- [ -n "${SRC_TIP:-}" ] && printf 'base %s\n' "$(printf '%s %s' "$1" "$SRC_TIP" | cut -c1-70)"
370
- [ -n "${CHG_TIP:-}" ] && printf 'compare %s\n' "$(printf '%s %s' "$2" "$CHG_TIP" | cut -c1-70)"
371
- printf 'showing %s\n' "$3"
372
- printf 'command %s\n' "$4"
373
- # A dropped file is named here rather than left to be noticed as missing.
374
- # The tab is what drop_unrenderable writes between the two fields.
375
- [ -s "${SKIP:-}" ] && while IFS="$(printf '\t')" read -r p len; do
376
- printf 'skipped %s (one line is %s chars, and diffnav aborts on it)\n' "$p" "$len"
377
- done < "$SKIP"
378
- return 0
379
- }
380
- launch() { # launch <source> <change> <what> <git-args...>
381
- local src="$1" chg="$2" what="$3"; shift 3
382
- local cmd="git $*"
383
- local tmp; tmp=$(mktemp); SKIP=$(mktemp)
384
- git "$@" 2>/dev/null | drop_unrenderable "$SKIP" > "$tmp"
385
- if grep -q '^diff --git' "$tmp"; then
386
- # diffnav puts the line after "commit <sha>" in its status bar, and the bar
387
- # stays put while you walk the file tree. A git show stream carries its own
388
- # header, so only a plain diff needs one synthesised.
389
- { [ "${1:-}" = diff ] && printf 'commit %s\n' "$(git rev-parse HEAD 2>/dev/null)"
390
- banner "$src" "$chg" "$what" "$cmd"; echo; cat "$tmp"; } | diffnav
391
- else
392
- # Nothing for diffnav to draw, for one of two different reasons. The
393
- # banner and the wait for a keypress are the same either way, so only the
394
- # explanation sits inside the branch.
395
- banner "$src" "$chg" "$what" "$cmd"
396
- echo
397
- if [ -s "$SKIP" ]; then
398
- echo " Every file that changed carries a line too long for diffnav."
399
- echo " They are named above, and nothing else differs between these two."
400
- else
401
- echo " No difference between these two. Nothing to show."
402
- local dirty; dirty=$(git status --porcelain 2>/dev/null | grep -cv '^??')
403
- if [ "${dirty:-0}" -gt 0 ]; then
404
- echo " You do have $dirty file(s) changed but not committed."
405
- echo " Press alt+x and pick \"my uncommitted changes\" to see them."
406
- else
407
- echo " Press alt+x to compare something else."
408
- fi
409
- fi
410
- echo; echo "Press any key..."; read -rsn1
411
- fi
412
- rm -f "$tmp" "$SKIP"
413
- }
414
-
415
- # ---- comparisons ----------------------------------------------------------
416
- against() { # against <base-ref> <committed|everything>
417
- local base="$1" mode="$2" mb
418
- SRC_TIP=$(tip "$base")
419
- mb=$(git merge-base "$base" HEAD 2>/dev/null); mb=${mb:0:9}
420
- [ -n "$mb" ] || { echo "No common history between $base and $BRANCH."
421
- echo; echo "Press any key..."; read -rsn1; return 1; }
422
- if [ "$mode" = committed ]; then
423
- CHG_TIP=$(tip HEAD)
424
- launch "$base" "$BRANCH" \
425
- "commits on $BRANCH that $base does not have" \
426
- diff "$base...HEAD"
427
- else
428
- CHG_TIP="the files as they sit on disk right now"
429
- launch "$base" "$BRANCH plus my uncommitted changes" \
430
- "everything $BRANCH has since it left $base, committed or not" \
431
- diff "$mb"
432
- fi
433
- }
434
- local_only() {
435
- SRC_TIP=$(tip HEAD); CHG_TIP="the files as they sit on disk right now"
436
- launch "the newest commit on $BRANCH" "my uncommitted changes" \
437
- "edits I have made but not committed, staged or not" diff HEAD
438
- }
439
- one_commit() { # one_commit <sha>
440
- SRC_TIP=$(tip "$1^" 2>/dev/null); CHG_TIP=$(tip "$1")
441
- launch "the commit just before $1" "commit $1" "what this one commit changed" \
442
- show "$1" -m --first-parent
443
- }
444
- auto() { # fallback when there is no branch comparison to make; $1 says why
445
- local why="${1:-nothing new to compare against another branch}"
446
- if ! git diff --quiet || ! git diff --cached --quiet; then local_only
447
- else SRC_TIP=$(tip HEAD^ 2>/dev/null); CHG_TIP=$(tip HEAD)
448
- launch "$BRANCH one commit back" "$BRANCH at its newest commit" \
449
- "$why, and nothing is uncommitted, so here is that commit alone" \
450
- show HEAD -m --first-parent; fi
451
- }
452
-
453
- # ---- picker ---------------------------------------------------------------
454
- strip_ansi() { sed 's/\x1b\[[0-9;]*m//g'; }
455
- pick_ref() { # pick_ref <prompt> -> a branch name
456
- git for-each-ref --sort=-committerdate --format='%(refname:short)' \
457
- refs/heads refs/remotes 2>/dev/null | grep -v '^origin/HEAD$' \
458
- | fzf --prompt="$1 > " --no-multi --cycle --preview-window='right,60%' \
459
- --preview='git log --oneline --decorate --color=always -20 {}'
460
- }
461
- pick_commit() { # pick_commit -> a short sha
462
- git log --oneline --decorate --color=always -300 2>/dev/null \
463
- | fzf --ansi --prompt="commit > " --no-multi --cycle --preview-window='right,60%' \
464
- --preview='git show --stat --color=always {1}' \
465
- | strip_ansi | awk '{print $1}'
466
- }
467
- pick() {
468
- command -v fzf >/dev/null 2>&1 || { echo "fzf is not installed."; echo; auto; return; }
469
- local dev def choice a b
470
- dev=$(dev_ref); def=$(default_ref)
471
- local -a rows=()
472
- row() { rows+=("$(printf '%s\t%-24s ==> %-30s %s' "$1" "$2" "$3" "$4")"); }
473
- [ -n "$dev" ] && [ "$dev" != "$BRANCH" ] && {
474
- row dev-committed "$dev" "$BRANCH" "committed work only"
475
- row dev-everything "$dev" "$BRANCH + uncommitted" "committed work plus what is not committed"; }
476
- [ -n "$def" ] && [ "$def" != "$BRANCH" ] && [ "$def" != "$dev" ] && {
477
- row def-committed "$def" "$BRANCH" "committed work only"
478
- row def-everything "$def" "$BRANCH + uncommitted" "committed work plus what is not committed"; }
479
- row local "HEAD" "my uncommitted work" "staged and unstaged changes only"
480
- row commit "its parent" "one commit" "pick a commit from this branch's history"
481
- row branch "any branch" "any branch" "pick the source, then the branch to compare"
482
- choice=$(printf '%s\n' "${rows[@]}" \
483
- | fzf --delimiter=$'\t' --with-nth=2.. --no-multi --cycle \
484
- --prompt="diff > " --header="on $BRANCH · source ==> change" \
485
- | cut -f1)
486
- case "$choice" in
487
- dev-committed) against "$dev" committed ;;
488
- dev-everything) against "$dev" everything ;;
489
- def-committed) against "$def" committed ;;
490
- def-everything) against "$def" everything ;;
491
- local) local_only ;;
492
- commit) a=$(pick_commit); [ -n "$a" ] && one_commit "$a" ;;
493
- branch) a=$(pick_ref "source (compare against)") ; [ -z "$a" ] && exit 0
494
- b=$(pick_ref "change (the new work)") ; [ -z "$b" ] && exit 0
495
- SRC_TIP=$(tip "$a"); CHG_TIP=$(tip "$b")
496
- launch "$a" "$b" "commits on $b that $a does not have" diff "$a...$b" ;;
497
- *) exit 0 ;;
498
- esac
499
- }
500
-
501
- # ---- entry ----------------------------------------------------------------
502
- default_compare() { # alt+f: the newest commit on this branch, against origin/dev
503
- local base; base=$(dev_ref) || base=$(default_ref)
504
- # Only when this checkout IS the base branch is there nothing to compare.
505
- if [ -z "$base" ] || [ "$base" = "$BRANCH" ] || [ "origin/$BRANCH" = "$base" ]; then
506
- auto "$BRANCH is the branch everything else compares against"; return; fi
507
- against "$base" committed
508
- }
509
- case "${1:-}" in
510
- "") default_compare ;;
511
- pick) pick ;;
512
- auto) auto ;;
513
- *) echo "usage: acstack-diff [pick|auto]"; exit 2 ;;
514
- esac
515
- H
516
- chmod +x "$BIN/acstack-diff"
517
-
518
- cat > "$BIN/acstack-dash" <<'H'
519
- #!/usr/bin/env bash
520
- # gh-dash with a section for the repo you are standing in. Installed by acstack.
521
- set -uo pipefail
522
- # Every action gh-dash takes shells out to gh and discards its stderr, so a
523
- # refusal from GitHub reaches you as "exit status 1" for two seconds. acstack-gh
524
- # goes in front of the real gh under that name and speaks the refusal aloud.
525
- # Its directory holds nothing else, so nothing else on PATH is shadowed.
526
- SHIM="$(mktemp -d)"; GEN=""
527
- trap 'rm -rf "$SHIM"; rm -f "$GEN"' EXIT
528
- ln -s "$(command -v acstack-gh)" "$SHIM/gh" 2>/dev/null && export PATH="$SHIM:$PATH"
529
- BASE="$HOME/.config/gh-dash/config.yml"
530
- REPO="$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null)"
531
- # Not exec, so the trap above still gets to clean up after the dashboard.
532
- [ -z "$REPO" ] && { gh dash "$@"; exit $?; }
533
- GEN="$(mktemp)"
534
- REPO="$REPO" python3 - "$BASE" "$GEN" <<'PY'
535
- import os, sys, yaml
536
- cfg = yaml.safe_load(open(sys.argv[1])) or {}
537
- repo = os.environ["REPO"]; short = repo.split("/")[-1]
538
- s = [x for x in cfg.get("prSections", []) if x.get("title") != short]
539
- s.insert(0, {"title": short, "filters": f"repo:{repo} is:open"})
540
- cfg["prSections"] = s
541
- yaml.safe_dump(cfg, open(sys.argv[2], "w"), sort_keys=False)
542
- PY
543
- gh dash --config "$GEN" "$@"
544
- H
545
- chmod +x "$BIN/acstack-dash"
546
-
547
- cat > "$BIN/acstack-db" <<'H'
548
- #!/usr/bin/env bash
549
- # Open harlequin, the SQL editor, on one of your connection profiles.
550
- #
551
- # acstack-db one profile launches straight in, more than one opens a menu
552
- # acstack-db <profile> that profile, no menu
553
- #
554
- # Profiles live in a TOML file of your own. This writes a starter the first
555
- # time and never touches it again. Installed by acstack.
556
- set -uo pipefail
557
- # apply rewrites this line from harlequin.config_path. What ships here is the
558
- # default, so the helper is right even on a machine nothing rewrote it on.
559
- DB_CFG_DEFAULT="~/.config/acstack/harlequin.toml" # acstack:config_path
560
- CFG="${ACSTACK_DB_CONFIG:-$DB_CFG_DEFAULT}"
561
- # A human writes ~ for home, and a human writes this config. Expanding it here
562
- # rather than at write time keeps the helper right on a machine whose HOME is
563
- # not the one apply ran under.
564
- case "$CFG" in
565
- "~") CFG="$HOME" ;;
566
- "~/"*) CFG="$HOME/${CFG#\~/}" ;;
567
- esac
568
-
569
- command -v harlequin >/dev/null 2>&1 || {
570
- echo "harlequin is not installed. Run: setup.sh apply"; exit 1; }
571
-
572
- if [ ! -f "$CFG" ]; then
573
- mkdir -p "$(dirname "$CFG")"
574
- cat > "$CFG" <<'T'
575
- # harlequin connection profiles. acstack wrote this once and will not write it
576
- # again. Edit it freely.
577
- default_profile = "local"
578
-
579
- [profiles.local]
580
- adapter = "postgres"
581
- conn_str = "postgres://localhost:5432/postgres"
582
-
583
- # Snowflake. Take the # marks off and fill in your own account.
584
- #
585
- # Put the credentials in ~/.snowflake/connections.toml and name the connection
586
- # here, rather than writing them into this file.
587
- #
588
- # externalbrowser authentication needs a browser on this machine. A dev machine
589
- # reached over SSH has none, so use a key pair or a password instead.
590
- #
591
- # [profiles.snowflake]
592
- # adapter = "snowflake"
593
- # connection_name = "default"
594
- T
595
- echo "wrote a starter config at $CFG - edit it to reach your own database"
596
- fi
597
-
598
- launch() { exec harlequin --config-path "$CFG" -P "$1"; }
599
- [ $# -gt 0 ] && launch "$1"
600
-
601
- # Profile names, read with a regex rather than tomllib: tomllib needs python
602
- # 3.11 and nothing else this script writes has a floor that high. Names are all
603
- # that is wanted here, and a table header is the one line a regex reads safely.
604
- rows=$(python3 - "$CFG" <<'PY'
605
- import re, sys
606
- text = open(sys.argv[1]).read()
607
- d = re.search(r'(?m)^\s*default_profile\s*=\s*"([^"]+)"', text)
608
- default = d.group(1) if d else ""
609
- for m in re.finditer(r'(?m)^\s*\[profiles\.("?)([^\]"]+)\1\]\s*$', text):
610
- name = m.group(2)
611
- body = text[m.end():].split("\n[", 1)[0]
612
- a = re.search(r'(?m)^\s*adapter\s*=\s*"([^"]+)"', body)
613
- mark = " (default)" if name == default else ""
614
- print("%s\t%-24s %-12s%s" % (name, name, a.group(1) if a else "?", mark))
615
- PY
616
- )
617
- count=$(printf '%s\n' "$rows" | grep -c .)
618
- case "$count" in
619
- 0) echo "no profiles in $CFG"; exit 1 ;;
620
- 1) launch "$(printf '%s' "$rows" | cut -f1)" ;;
621
- esac
622
-
623
- command -v fzf >/dev/null 2>&1 || {
624
- echo "fzf is not installed, so there is no menu. Naming a profile still works:"
625
- printf '%s\n' "$rows" | cut -f1 | sed 's/.*/ acstack-db "&"/'
626
- exit 1; }
627
-
628
- choice=$(printf '%s\n' "$rows" \
629
- | fzf --delimiter=$'\t' --with-nth=2.. --no-multi --cycle \
630
- --prompt="db > " --header="harlequin · profile adapter" \
631
- | cut -f1)
632
- [ -n "$choice" ] || exit 0
633
- launch "$choice"
634
- H
635
- chmod +x "$BIN/acstack-db"
636
- # The one helper with a path in the config, so the one write that is not a
637
- # heredoc on its own. The line above already holds the default, so this
638
- # carries nothing but a value the user chose.
639
- ACSTACK_DB_CFG="$(cfg_get harlequin.config_path "~/.config/acstack/harlequin.toml")" \
640
- py "$BIN/acstack-db" <<'PY'
641
- import os, re, sys
642
- # A bash single-quoted literal, so a path with a space or a quote in it reaches
643
- # the helper as one word rather than as shell syntax.
644
- v = "'" + os.environ["ACSTACK_DB_CFG"].replace("'", "'\\''") + "'"
645
- p = sys.argv[1]
646
- t = open(p).read()
647
- t, n = re.subn(r"(?m)^DB_CFG_DEFAULT=.*# acstack:config_path$",
648
- lambda m: "DB_CFG_DEFAULT=" + v + " # acstack:config_path", t)
649
- if n:
650
- open(p, "w").write(t)
651
- PY
652
-
653
- cat > "$BIN/acstack-gh" <<'H'
654
- #!/usr/bin/env bash
655
- # gh, plus the reason a pull request action failed.
656
- #
657
- # gh-dash runs gh for everything it does to a pull request - approve, comment,
658
- # merge - and throws the command's stderr away, so GitHub refusing one shows as
659
- # "exit status 1" in the footer for two seconds and nothing else. Approving your
660
- # own pull request is the everyday case: GitHub always refuses that, and the
661
- # dashboard looks like it simply ignored the keypress.
662
- #
663
- # acstack-dash links this into a directory of its own at the front of $PATH
664
- # under the name gh, so gh-dash finds it first. Everything is handed to the real
665
- # gh untouched and its exit code is returned as-is; the only thing added is a
666
- # notification when an action fails, because a suspended TUI cannot print for
667
- # us. Installed by acstack.
668
- set -uo pipefail
669
-
670
- # The real gh is the first one on $PATH that is not this script under another
671
- # name. Comparing what each resolves to sees through both the link acstack-dash
672
- # makes and a plain copy of this file.
673
- ME="$(readlink -f "$0")"
674
- REAL=""
675
- while IFS= read -r dir; do
676
- [ -n "$dir" ] && [ -x "$dir/gh" ] || continue
677
- [ "$(readlink -f "$dir/gh")" = "$ME" ] && continue
678
- REAL="$dir/gh"; break
679
- done < <(printf '%s\n' "${PATH//:/$'\n'}")
680
- [ -n "$REAL" ] || { echo "acstack-gh: no gh on \$PATH besides this shim" >&2; exit 127; }
681
-
682
- # The dashboard is not an action: it owns the terminal for as long as it runs.
683
- [ "${1:-}" = "dash" ] && exec "$REAL" "$@"
684
-
685
- ERR="$(mktemp)"; trap 'rm -f "$ERR"' EXIT
686
- "$REAL" "$@" 2>"$ERR"
687
- RC=$?
688
- cat "$ERR" >&2
689
-
690
- # Only what a dashboard key fires. gh's own plumbing - repo view, api, auth -
691
- # fails for reasons a notification cannot help with, and acstack-dash asks gh
692
- # which repository this is before the dashboard has even started.
693
- case "${1:-}" in pr|issue) ;; *) exit $RC ;; esac
694
- [ "$RC" -eq 0 ] && exit 0
695
-
696
- MSG="$(grep -v '^[[:space:]]*$' "$ERR" | head -3)"
697
- [ -n "$MSG" ] || MSG="gh exited with status $RC"
698
- command -v herdr >/dev/null 2>&1 \
699
- && herdr notification show "gh ${1:-} ${2:-} failed" --body "$MSG" >/dev/null 2>&1
700
- exit $RC
701
- H
702
- chmod +x "$BIN/acstack-gh"
703
-
704
- cat > "$BIN/acstack-md" <<'H'
705
- #!/usr/bin/env bash
706
- # Read a markdown file in the terminal with its mermaid diagrams drawn as text.
707
- #
708
- # acstack-md FILE.md render to the terminal
709
- # acstack-md --preview FILE.md render into a preview pane: width from
710
- # $COLUMNS, straight to stdout, no pager
711
- # acstack-md --numbered FILE.md the source instead, line numbers down the
712
- # left, for quoting a line back in a review
713
- # acstack-md --toggle-line-numbers flip which of the two --preview shows
714
- # acstack-md --diagram N FILE.md one diagram alone, with horizontal scroll
715
- # acstack-md --serve FILE.md serve it for a real browser, print the link
716
- # acstack-md --html FILE.md print that same page as one HTML document
717
- # acstack-md --browser FILE.md open it in the browser you are sitting at
718
- #
719
- # While reading, b opens the page in that browser and # switches between the
720
- # rendered document and its source with line numbers. $ACSTACK_MD_BROWSER_KEY
721
- # and $ACSTACK_MD_LINE_NUMBERS_KEY move them.
722
- #
723
- # Text, not images, is deliberate: herdr emits the kitty graphics protocol and
724
- # no terminal reachable from Windows or Android renders that, and e-ink panels
725
- # render text far better than bitmaps anyway. Installed by acstack.
726
- set -uo pipefail
727
-
728
- PORT="${ACSTACK_MD_PORT:-6419}"
729
- # Named rather than inherited from an ambient $AGENT_BROWSER_CDP, for the
730
- # reason acstack-open gives at its own bridge branch: leaning on the ambient
731
- # one launches a headless Chrome nobody can see and calls it success.
732
- BRIDGE="${ACSTACK_BRIDGE_URL:-http://127.0.0.1:19988}"
733
- OPENER="${ACSTACK_OPENER_URL:-http://127.0.0.1:19989}"
734
- # The opener refuses anything without this, so its absence is the same as the
735
- # opener being down - which is exactly how the route probe treats it.
736
- OPENER_TOKEN="${XDG_CONFIG_HOME:-$HOME/.config}/acstack/opener.token"
737
- SERVE=0; ONLY=0; PREVIEW=0; HTML=0; BROWSER=0; NUMBERED=0; TOGGLE=0
738
- usage() { echo "usage: acstack-md [--preview] [--numbered] [--toggle-line-numbers] [--diagram N] [--serve|--html|--browser] FILE.md" >&2; exit 1; }
739
- # The preview pane and the key that flips it are two processes that never meet,
740
- # so the choice between the rendered view and the source lives in a file both
741
- # of them can find. One name, defined once, is what keeps them agreeing.
742
- LINE_NUMBER_FLAG="${XDG_STATE_HOME:-$HOME/.local/state}/acstack/md-line-numbers"
743
- # A loop rather than a fixed order, so --preview composes with --diagram and
744
- # a caller can pass them either way round.
745
- while [ $# -gt 0 ]; do
746
- case "$1" in
747
- --serve) SERVE=1; shift ;;
748
- --html) HTML=1; shift ;;
749
- --browser) BROWSER=1; shift ;;
750
- --preview) PREVIEW=1; shift ;;
751
- --numbered) NUMBERED=1; shift ;;
752
- --toggle-line-numbers) TOGGLE=1; shift ;;
753
- --diagram) ONLY="${2:-1}"; shift; [ $# -gt 0 ] && shift ;;
754
- --) shift; break ;;
755
- --*) usage ;;
756
- *) break ;;
757
- esac
758
- done
759
- # The toggle names no file: it changes what the next preview will draw and has
760
- # nothing to draw itself. So it answers here, before the file check below.
761
- if [ "$TOGGLE" = "1" ]; then
762
- if [ -e "$LINE_NUMBER_FLAG" ]; then
763
- rm -f "$LINE_NUMBER_FLAG"
764
- echo "acstack-md: markdown previews render again"
765
- else
766
- # A machine that has never had a state directory is every machine the
767
- # first time, so make it rather than fail on it.
768
- mkdir -p "$(dirname "$LINE_NUMBER_FLAG")" && : > "$LINE_NUMBER_FLAG" \
769
- || { echo "acstack-md: could not write $LINE_NUMBER_FLAG" >&2; exit 1; }
770
- echo "acstack-md: markdown previews show source with line numbers"
771
- fi
772
- exit 0
773
- fi
774
- # --preview, --serve and --html are three answers to the same question - where
775
- # does this end up - so at most one can be true. A preview pane is somewhere to
776
- # read, not somewhere to run a server or hand a document out of.
777
- if [ $((SERVE + PREVIEW + HTML + BROWSER)) -gt 1 ]; then
778
- echo "acstack-md: --preview, --serve, --html and --browser do different things - pick one" >&2
779
- exit 1
780
- fi
781
- # --numbered answers a different question - which of the two views - so it
782
- # composes with --preview and with nothing else. The others all want the
783
- # rendered document, and numbered source is not one.
784
- if [ "$NUMBERED" = "1" ] && [ $((SERVE + HTML + BROWSER)) -gt 0 ]; then
785
- echo "acstack-md: --numbered prints the source, so --serve, --html and --browser have nothing to render" >&2
786
- exit 1
787
- fi
788
- # The flag is what the file manager's key flips, and the pane is the only
789
- # place it applies: every other route was asked for a rendered document by
790
- # name, and the opener menu lists the numbered read as its own entry.
791
- #
792
- # --diagram is the exception the pane itself holds. It names one drawing to
793
- # show, which is a different question from which of the two views the pane is
794
- # on, and a reader who left the flag set and then asked for a diagram still
795
- # wants the diagram. So an explicit --diagram outranks the flag. An explicit
796
- # --numbered still wins over it, for the same reason in the other direction.
797
- if [ "$PREVIEW" = "1" ] && [ "$ONLY" = "0" ] && [ -e "$LINE_NUMBER_FLAG" ]; then NUMBERED=1; fi
798
- # Node labels set a diagram's width, so padding cannot rescue a wide one.
799
- # Tightening still buys roughly a third of the height back.
800
- PAD="${ACSTACK_MD_PAD:--x 2 -y 2}"
801
- # A caller that exports COLUMNS means it, so it wins outright. tput reads
802
- # COLUMNS too, ahead of terminfo, but that is a side effect worth naming
803
- # rather than resting on - it goes when TERM is unset and tput fails.
804
- case "${COLUMNS:-}" in
805
- ''|*[!0-9]*) COLS=$(tput cols 2>/dev/null || echo 80) ;;
806
- *) COLS="$COLUMNS" ;;
807
- esac
808
- [ "$COLS" -gt 0 ] 2>/dev/null || COLS=80
809
- FILE="${1:-}"
810
- [ -n "$FILE" ] && [ -f "$FILE" ] || usage
811
- # One place to clean up, so no exit path leaks a temp file.
812
- trap "rm -f /tmp/acstack-md.$$.md /tmp/acstack-md-out.$$.md /tmp/acstack-md-art.$$.* /tmp/acstack-md-keys.$$" EXIT
813
-
814
- # Wanting a document in a browser happens while reading it, so the key belongs
815
- # in the pager rather than before or after one.
816
- #
817
- # less has no action that runs a fixed command, and its one shell escape
818
- # expands % to the file it was handed - which here is the rendered temp copy,
819
- # not the markdown someone asked for. So the key quits instead: lesskey's quit
820
- # action takes the first character of its extra string as the exit status, and
821
- # "A" is 65, a value less never returns of its own accord. Nothing has to be
822
- # passed through the binding, because this script already knows the file.
823
- BROWSE_STATUS=65
824
- BROWSE_KEY="${ACSTACK_MD_BROWSER_KEY:-b}"
825
- # The same trick a second time, for the other thing a reader wants mid-document.
826
- # Wanting a line number happens while reading too - that is the moment you are
827
- # about to quote one - and by then the file list with its own binding is not in
828
- # front of you. So the key means the same thing in both places.
829
- NUMBER_STATUS=66
830
- NUMBER_KEY="${ACSTACK_MD_LINE_NUMBERS_KEY:-#}"
831
- KEYS="/tmp/acstack-md-keys.$$"
832
-
833
- # A preview pane cannot page, so the same render goes straight to stdout.
834
- emit() {
835
- local f="$1" nk rc; shift
836
- if [ "$PREVIEW" = "1" ]; then cat "$f"; return; fi
837
- # The binding is a less feature, so only less is handed it. Setting
838
- # LESSKEYIN for a pager that never reads it is at best noise, and acting on
839
- # an exit status that pager chose for its own reasons is a bug waiting.
840
- case "${1:-}" in
841
- less|*/less)
842
- # b is back-a-page in less. ^B and PageUp both still do that, so this
843
- # costs nothing (measured). LESSKEYIN wants less 582 or newer; older
844
- # versions ignore it and quietly keep b as it was.
845
- #
846
- # lesskey reads a line starting with # as a comment, so the key that
847
- # means line numbers has to be escaped or the binding is silently
848
- # dropped and the key does nothing but ring the bell - measured on less
849
- # 590, and how this arrived as a bug report. Only a leading # needs it:
850
- # backslash means something of its own to lesskey, and \b would bind
851
- # backspace rather than the letter.
852
- case "$NUMBER_KEY" in
853
- '#'*) nk="\\$NUMBER_KEY" ;;
854
- *) nk="$NUMBER_KEY" ;;
855
- esac
856
- { printf '#command\n'
857
- printf '%s quit A\n' "$BROWSE_KEY"
858
- printf '%s quit B\n' "$nk"
859
- } > "$KEYS"
860
- LESSKEYIN="$KEYS" "$@" "$f"
861
- rc=$?
862
- if [ "$rc" = "$BROWSE_STATUS" ]; then
863
- "$0" --browser "$FILE"
864
- exit $?
865
- fi
866
- if [ "$rc" = "$NUMBER_STATUS" ]; then
867
- # One key, both directions: the view you are not in is the one it
868
- # takes you to. $FILE is the markdown throughout, never the rendered
869
- # temp copy, so either re-read starts from the source again.
870
- if [ "$NUMBERED" = "1" ]; then "$0" "$FILE"; else "$0" --numbered "$FILE"; fi
871
- exit $?
872
- fi
873
- ;;
874
- *) "$@" "$f" ;;
875
- esac
876
- }
877
-
878
- # A wide diagram left a marker behind rather than art glow would wrap. Turn
879
- # the marker into a line a person can read and put the drawing back under it.
880
- # Both output paths run this: without it the marker reaches the reader raw.
881
- splice() {
882
- ACSTACK_PID=$$ ACSTACK_PREVIEW="$PREVIEW" python3 - "$1" <<'PY'
883
- import os, pathlib, re, sys
884
- f = pathlib.Path(sys.argv[1])
885
- pid = os.environ["ACSTACK_PID"]
886
- preview = os.environ.get("ACSTACK_PREVIEW") == "1"
887
- lines = []
888
- for line in f.read_text().split("\n"):
889
- m = re.search(r"\x00ACSTACKART(\d+)\x00", line)
890
- if not m:
891
- lines.append(line); continue
892
- art = pathlib.Path(f"/tmp/acstack-md-art.{pid}.{m.group(1)}")
893
- lines.append(re.sub(r"\x00ACSTACKART\d+\x00", f"Diagram {m.group(1)}:", line))
894
- if art.exists():
895
- # Art too wide for the pane would spill past it, so a preview pane
896
- # keeps the placeholder and leaves the drawing to a full-width read.
897
- if not preview:
898
- lines.extend(" " + l for l in art.read_text().splitlines())
899
- art.unlink()
900
- f.write_text("\n".join(lines) + "\n")
901
- PY
902
- }
903
-
904
- # Rendered markdown has no line numbers, and a reader who wants to quote a
905
- # line back in a review needs the file's own. The gutter is as wide as the
906
- # largest number in it, so the source stays on one column whatever the length.
907
- numbered() { # numbered <file> -> stdout
908
- local total width avail
909
- total=$(awk 'END { print NR }' "$1")
910
- width=${#total}
911
- [ "$width" -lt 3 ] && width=3
912
- if [ "$PREVIEW" = "1" ]; then
913
- # A wrapped line makes the gutter lie about which source line you are
914
- # looking at, and a pane has no arrow keys to pan with, so it chops.
915
- avail=$((COLS - width - 2))
916
- [ "$avail" -lt 1 ] && avail=1
917
- awk -v w="$width" -v a="$avail" \
918
- '{ printf "%" w "d %s\n", NR, substr($0, 1, a) }' "$1"
919
- else
920
- awk -v w="$width" '{ printf "%" w "d %s\n", NR, $0 }' "$1"
921
- fi
922
- }
923
-
924
- if [ "$NUMBERED" = "1" ]; then
925
- numbered "$FILE" > /tmp/acstack-md.$$.md
926
- # -S chops rather than wraps, so the arrow keys pan across a long line
927
- # instead of the gutter losing its column. emit sends a pane straight to
928
- # stdout and keeps the browser key working in the pager, both unchanged.
929
- emit /tmp/acstack-md.$$.md less -SR
930
- exit 0
931
- fi
932
-
933
- # --browser names a destination rather than a rendering: work out where the
934
- # browser actually is, then pick the delivery that reaches it. acstack-open
935
- # already answers that question for URLs, so ask it rather than deciding the
936
- # same thing twice and drifting from it later.
937
- if [ "$BROWSER" = "1" ]; then
938
- case "$(command -v acstack-open >/dev/null 2>&1 && acstack-open --why 2>/dev/null)" in
939
- opener)
940
- # The opener stores the document on the laptop and serves it to the
941
- # browser there, so the page still works once this machine stops
942
- # answering - and a re-render of the same file updates the tab already
943
- # showing it instead of stacking up a second one. That reuse is why the
944
- # key has to be stable per file: it is the absolute path, hex-encoded
945
- # because a path may hold anything a URL cannot.
946
- tok=$(cat "$OPENER_TOKEN" 2>/dev/null || true)
947
- if [ -z "$tok" ]; then
948
- echo "acstack-md: no opener token at $OPENER_TOKEN." >&2
949
- echo " run register-tasks.ps1 on the laptop to install one." >&2
950
- exit 1
951
- fi
952
- hex() { od -An -v -tx1 | tr -d ' \n'; }
953
- key=$(printf '%s' "$(cd "$(dirname "$FILE")" && pwd -P)/$(basename "$FILE")" | hex)
954
- name=$(basename "$FILE")
955
- # Vendored mermaid, uploaded once so diagrams draw without reaching a
956
- # CDN. Best effort throughout: the opener redirects to the CDN when it
957
- # has no copy, so a failure here costs nothing but an outbound fetch.
958
- vendored="$HOME/.local/share/acstack/mermaid.min.js"
959
- # tr, because whitespace inside JSON carries no meaning and a match that
960
- # depends on its absence is a match waiting to break. Same reasoning as
961
- # acstack-open's /json/list check.
962
- if [ -s "$vendored" ] && ! curl -fsS -m 3 -H "X-ACStack-Token: $tok" \
963
- "$OPENER/health" 2>/dev/null | tr -d '[:space:]' | grep -q '"mermaid":true'; then
964
- curl -fsS -m 60 -X POST -H "X-ACStack-Token: $tok" \
965
- --data-binary "@$vendored" "$OPENER/asset/mermaid.js" >/dev/null 2>&1 || true
966
- fi
967
- # The opener answers with what it did, and that answer is what success
968
- # means - it says whether a tab was opened or a live one reused. An exit
969
- # status of 0 is not a page that arrived.
970
- answer=$(ACSTACK_MD_OPENER=1 "$0" --html "$FILE" \
971
- | curl -fsS -m 60 -X POST -H "X-ACStack-Token: $tok" \
972
- -H 'Content-Type: text/html; charset=utf-8' --data-binary @- \
973
- "$OPENER/doc?key=$key&title=$(printf '%s' "$name" | hex)" 2>/dev/null \
974
- | tr -d '[:space:]')
975
- case "$answer" in
976
- *'"reused":true'*)
977
- printf '%s updated in the tab already open on your machine\n' "$name" >&2
978
- exit 0 ;;
979
- *'"opened":true'*)
980
- printf '%s is on screen in the browser on your machine\n' "$name" >&2
981
- exit 0 ;;
982
- esac
983
- echo "acstack-md: the opener did not confirm the page reached a browser." >&2
984
- echo " check it is up: acstack-open --why" >&2
985
- exit 1
986
- ;;
987
- bridge)
988
- # Under `herdr --remote` the tunnel runs laptop-to-here, so nothing over
989
- # there can open a port on this machine and a link to localhost:6419
990
- # names the wrong localhost. Hand the whole document down the CDP link
991
- # that is already open instead, and let Chrome hold it. That is also why
992
- # --html exists: a document travels, a port does not.
993
- #
994
- # $0 is the path bash resolved this script to - absolute when it came off
995
- # PATH - so the page pushed is the one --html renders, not a second copy
996
- # of the renderer that can disagree with it.
997
- html=$("$0" --html "$FILE") || exit 1
998
- # base64 because the document is full of quotes and newlines and has to
999
- # survive being a JavaScript string literal. tr rather than `base64 -w0`,
1000
- # which is GNU-only.
1001
- payload=$(printf '%s' "$html" | base64 | tr -d '\n')
1002
- # A tab of its own, and never over a page that is already there. The
1003
- # extension attaches per tab through chrome.debugger, and rewriting a
1004
- # real document detaches it - measured: pushing over an https page left
1005
- # /json/list empty and the bridge unusable until it was armed again by
1006
- # hand. A fresh about:blank tab survives the same rewrite, because the
1007
- # document being replaced has the same origin. So the armed tab is left
1008
- # exactly as it was, and this writes into a new one.
1009
- #
1010
- # The default session deliberately, as acstack-open explains: the relay
1011
- # takes one CDP client at a time, so a session of our own could not
1012
- # connect while an agent holds it.
1013
- #
1014
- # The last expression is the page title, so the browser answers with
1015
- # what it is now holding. That answer is what success means here - a
1016
- # command that exited 0 is not a page that arrived.
1017
- name=$(basename "$FILE")
1018
- timeout 20 agent-browser --cdp "$BRIDGE" tab new >/dev/null 2>&1
1019
- landed=$(printf 'const b64="%s";const doc=new TextDecoder().decode(Uint8Array.from(atob(b64),c=>c.charCodeAt(0)));document.open();document.write(doc);document.close();document.title;\n' \
1020
- "$payload" \
1021
- | timeout 60 agent-browser --cdp "$BRIDGE" eval --stdin 2>/dev/null)
1022
- # Writing into the tab is only half of it. A tab created over CDP is
1023
- # created in the background, so it has to be brought forward or the page
1024
- # is real, correct, and never seen - which is exactly how this first went
1025
- # wrong. agent-browser carries Page.bringToFront on its tab switch, so
1026
- # switch to the tab just written into, which is the one it already has
1027
- # selected. Best effort: a page in a background tab still beats an error.
1028
- # The arrow marks the active tab, which is the one just written into.
1029
- # Taking the first line instead would raise whatever sits at index 0 -
1030
- # somebody else's tab, and the page still unseen.
1031
- idx=$(timeout 20 agent-browser --cdp "$BRIDGE" tab list 2>/dev/null \
1032
- | grep -m1 -- "$(printf '\342\206\222')" \
1033
- | sed -n 's/.*\[\([0-9][0-9]*\)\].*/\1/p')
1034
- [ -n "$idx" ] && timeout 20 agent-browser --cdp "$BRIDGE" tab "$idx" >/dev/null 2>&1
1035
- case "$landed" in
1036
- *"$name"*)
1037
- printf '%s is on screen in the browser on your machine\n' "$name" >&2
1038
- exit 0 ;;
1039
- esac
1040
- # Serving instead would print a link the laptop resolves to its own
1041
- # localhost, which is a wrong answer dressed as a working one.
1042
- echo "acstack-md: the bridge answered but is not holding the page." >&2
1043
- echo " the document goes to the tab the Playwriter extension is armed on." >&2
1044
- echo " check something is still armed: acstack-open --why" >&2
1045
- exit 1
1046
- ;;
1047
- esac
1048
- # Every other route has a browser that can reach this machine's ports, or a
1049
- # terminal that can hand someone a link. Serving is already exactly that.
1050
- SERVE=1
1051
- fi
1052
-
1053
- if [ "$SERVE" = "1" ] || [ "$HTML" = "1" ]; then
1054
- # One program, two ways out: --serve keeps answering with the document,
1055
- # --html hands it over once and stops. Same renderer either way, so the two
1056
- # cannot drift into disagreeing about what the page looks like.
1057
- MODE=serve; NAME=acstack-md-serve
1058
- if [ "$HTML" = "1" ]; then MODE=html; NAME=acstack-md-html; fi
1059
- # A document bound for the opener on the laptop is handed over once, like
1060
- # --html, but it does end up behind a server - the opener's. So it wants the
1061
- # relative mermaid src that --html cannot use. Internal: set by the --browser
1062
- # dispatch below, never by a caller.
1063
- if [ "${ACSTACK_MD_OPENER:-0}" = "1" ] && [ "$HTML" = "1" ]; then MODE=opener; fi
1064
- # exec -a names the process. Without it a running server is just "python3 -"
1065
- # and there is nothing sensible to pkill.
1066
- exec -a "$NAME" python3 - "$FILE" "$PORT" "$MODE" <<'PY'
1067
- import html, http.server, pathlib, re, socketserver, sys
1068
- try:
1069
- import markdown
1070
- except ImportError:
1071
- sys.exit("acstack-md: needs the python markdown package: pip install --user markdown")
1072
-
1073
- src, port, MODE = pathlib.Path(sys.argv[1]), int(sys.argv[2]), sys.argv[3]
1074
- VENDOR = pathlib.Path.home() / ".local/share/acstack/mermaid.min.js"
1075
-
1076
- CSS = """*{box-sizing:border-box}body{max-width:54rem;margin:0 auto;padding:2rem 1.25rem;
1077
- font:16px/1.65 -apple-system,Segoe UI,system-ui,sans-serif;color:#1a1a1a;background:#fff}
1078
- h1,h2,h3{line-height:1.25;margin:2rem 0 .75rem}h1{font-size:1.9rem}h2{font-size:1.45rem}
1079
- code{font:14px/1.5 ui-monospace,Consolas,monospace;background:#f2f2f2;padding:.15em .35em;border-radius:3px}
1080
- pre{background:#f7f7f7;padding:1rem;border-radius:6px;overflow-x:auto}pre code{background:none;padding:0}
1081
- pre.mermaid{background:none;text-align:center}table{border-collapse:collapse;width:100%}
1082
- th,td{border:1px solid #ddd;padding:.4rem .6rem;text-align:left}
1083
- blockquote{margin:1rem 0;padding:.1rem 1rem;border-left:3px solid #ccc;color:#555}
1084
- img{max-width:100%}
1085
- @media(prefers-color-scheme:dark){body{background:#151515;color:#e6e6e6}
1086
- code{background:#242424}pre{background:#1d1d1d}th,td{border-color:#333}
1087
- blockquote{border-color:#444;color:#aaa}}"""
1088
-
1089
- def render():
1090
- body = markdown.markdown(src.read_text(),
1091
- extensions=["fenced_code", "tables", "toc", "sane_lists"])
1092
- # mermaid.js wants <pre class="mermaid">, not a highlighted code block.
1093
- body = re.sub(r'<pre><code class="language-mermaid">(.*?)</code></pre>',
1094
- lambda m: '<pre class="mermaid">' + html.unescape(m.group(1)) + "</pre>",
1095
- body, flags=re.S)
1096
- # --serve answers for /mermaid.js itself off the vendored copy. A document
1097
- # standing on its own has no server behind it, so a relative src would be
1098
- # fetched from whatever host the page ended up on and find nothing there -
1099
- # it names the CDN instead. Inlining the vendored 3.5MB would make the page
1100
- # offline-proof and far too big to push through a CDP payload, which is the
1101
- # whole reason --html exists.
1102
- # The opener serves the page from the laptop and answers /mermaid.js off
1103
- # the copy this machine uploaded to it - falling back to the CDN itself if
1104
- # it has none. So a relative src is right there too, and unlike --serve it
1105
- # does not depend on this machine having vendored anything.
1106
- js = ('<script src="/mermaid.js"></script>'
1107
- if MODE == "opener" or (MODE == "serve" and VENDOR.exists()) else
1108
- '<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>')
1109
- return f"""<!doctype html><html><head><meta charset="utf-8">
1110
- <meta name="viewport" content="width=device-width,initial-scale=1">
1111
- <title>{html.escape(src.name)}</title><style>{CSS}</style></head><body>
1112
- {body}
1113
- {js}
1114
- <script>mermaid.initialize({{startOnLoad:true,securityLevel:"loose",
1115
- theme:matchMedia("(prefers-color-scheme:dark)").matches?"dark":"default"}});</script>
1116
- </body></html>""".encode()
1117
-
1118
- # Out before the port is ever looked at: a document is not a server, and a
1119
- # caller that only wants the page must not be stopped by a busy 6419.
1120
- if MODE in ("html", "opener"):
1121
- sys.stdout.buffer.write(render())
1122
- raise SystemExit
1123
-
1124
- class H(http.server.BaseHTTPRequestHandler):
1125
- def do_GET(self):
1126
- if self.path == "/mermaid.js" and VENDOR.exists():
1127
- body, ctype = VENDOR.read_bytes(), "application/javascript"
1128
- else:
1129
- body, ctype = render(), "text/html; charset=utf-8" # re-read: edits show on reload
1130
- self.send_response(200)
1131
- self.send_header("Content-Type", ctype)
1132
- self.send_header("Content-Length", str(len(body)))
1133
- self.send_header("Cache-Control", "no-store")
1134
- self.end_headers()
1135
- self.wfile.write(body)
1136
- def log_message(self, *a): pass
1137
-
1138
- socketserver.TCPServer.allow_reuse_address = True
1139
- def holder(port):
1140
- """pid and command line of whatever is listening, best effort."""
1141
- import subprocess
1142
- try:
1143
- out = subprocess.run(["ss", "-ltnpH", f"sport = :{port}"],
1144
- capture_output=True, text=True, timeout=3).stdout
1145
- pid = re.search(r"pid=(\d+)", out)
1146
- if not pid:
1147
- return None, None
1148
- pid = pid.group(1)
1149
- cmd = pathlib.Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ").decode()
1150
- return pid, cmd.strip()
1151
- except Exception:
1152
- return None, None
1153
-
1154
- try:
1155
- srv = socketserver.TCPServer(("127.0.0.1", port), H)
1156
- except OSError as e:
1157
- pid, cmd = holder(port)
1158
- msg = [f"port {port} is busy: {e}"]
1159
- if pid:
1160
- mine = "acstack-md-serve" in cmd or "acstack-md" in cmd
1161
- msg.append(f" held by pid {pid}: {cmd[:90]}")
1162
- msg.append(f" stop it with: kill {pid}" if mine else
1163
- f" not acstack-md. use another port: ACSTACK_MD_PORT=<n> acstack-md --serve ...")
1164
- else:
1165
- msg.append(" could not identify the holder. try: ss -ltnp | grep " + str(port))
1166
- sys.exit("\n".join(msg))
1167
- url = f"http://localhost:{port}"
1168
- # OSC 8 makes it ctrl+clickable; the bare URL below covers terminals without it.
1169
- sys.stderr.write(f"\033]8;;{url}\033\\{src.name}\033]8;;\033\\ {url}\n")
1170
- sys.stderr.write(" reload the page after editing. ctrl+C to stop.\n")
1171
- if not VENDOR.exists():
1172
- sys.stderr.write(" note: mermaid.js not vendored, falling back to CDN\n")
1173
- try: srv.serve_forever()
1174
- except KeyboardInterrupt: pass
1175
- PY
1176
- fi
1177
-
1178
-
1179
- # Terminal render: swap each mermaid fence for its text drawing, then page it.
1180
- ACSTACK_COLS="$COLS" ACSTACK_PAD="$PAD" ACSTACK_ONLY="$ONLY" ACSTACK_RUN="$$" \
1181
- ACSTACK_PREVIEW="$PREVIEW" \
1182
- python3 - "$FILE" <<'PY' > /tmp/acstack-md.$$.md 2>/dev/null
1183
- import os, pathlib, re, shlex, shutil, subprocess, sys, tempfile
1184
- text = pathlib.Path(sys.argv[1]).read_text()
1185
- # "$HOME/..." rather than the absolute path: shorter, and unlike ~'/x y'
1186
- # it expands correctly when the path contains spaces.
1187
- _raw = sys.argv[1]
1188
- _home = str(pathlib.Path.home())
1189
- SELF = ('"$HOME' + _raw[len(_home):].replace('"', '\\"') + '"'
1190
- if _raw.startswith(_home) else shlex.quote(_raw))
1191
- have = shutil.which("mermaid-ascii")
1192
- COLS = int(os.environ.get("ACSTACK_COLS") or 80)
1193
- PAD = (os.environ.get("ACSTACK_PAD") or "").split()
1194
- ONLY = int(os.environ.get("ACSTACK_ONLY") or 0)
1195
- PREVIEW = os.environ.get("ACSTACK_PREVIEW") == "1"
1196
- # glow indents and pads a fenced block, so the art has less room than the pane.
1197
- BUDGET = max(20, COLS - 6)
1198
- seen = 0
1199
-
1200
- # mermaid-ascii understands `graph`/`flowchart` with [square] nodes. It draws
1201
- # styling directives as if they were nodes, and leaks any other shape syntax
1202
- # into the label, so both are normalised away before it sees the source.
1203
- DROP = re.compile(r"^\s*(style|classDef|class|linkStyle|click|%%)\b")
1204
- SHAPES = [
1205
- (re.compile(r"(\w+)\{\{(.+?)\}\}"), r"\1[\2]"), # {{hexagon}}
1206
- (re.compile(r"(\w+)\(\((.+?)\)\)"), r"\1[\2]"), # ((circle))
1207
- (re.compile(r"(\w+)\(\[(.+?)\]\)"), r"\1[\2]"), # ([stadium])
1208
- (re.compile(r"(\w+)\[\((.+?)\)\]"), r"\1[\2]"), # [(database)]
1209
- (re.compile(r"(\w+)\{(.+?)\}"), r"\1[\2]"), # {decision}
1210
- (re.compile(r"(\w+)\((.+?)\)"), r"\1[\2]"), # (rounded)
1211
- ]
1212
-
1213
- def to_ascii(src):
1214
- if not have:
1215
- return None, "mermaid-ascii not installed"
1216
- body = "\n".join(l for l in src.splitlines() if not DROP.match(l))
1217
- for pat, rep in SHAPES:
1218
- body = pat.sub(rep, body)
1219
- if not re.match(r"\s*(graph|flowchart|sequenceDiagram)\b", body):
1220
- kind = (body.strip().split(None, 1) or ["?"])[0]
1221
- return None, f"{kind} diagrams are not supported by mermaid-ascii"
1222
- with tempfile.NamedTemporaryFile("w", suffix=".mmd", delete=False) as f:
1223
- f.write(body); path = f.name
1224
- # mermaid-ascii reads a file, not stdin. A preview redraws on every cursor
1225
- # move, so a file left behind here fills /tmp at cursor speed.
1226
- try:
1227
- r = subprocess.run(["mermaid-ascii", "--file", path] + PAD,
1228
- capture_output=True, text=True)
1229
- finally:
1230
- os.unlink(path)
1231
- if r.returncode != 0 or not r.stdout.strip():
1232
- return None, (r.stderr.strip().splitlines() or ["could not draw it"])[0]
1233
- return r.stdout.rstrip("\n"), None
1234
-
1235
- def repl(m):
1236
- global seen
1237
- seen += 1
1238
- n = seen
1239
- art, err = to_ascii(m.group(1))
1240
- if not art:
1241
- # Keep the source visible rather than swallowing the diagram.
1242
- return (f"\n```\nDiagram {n} not drawn: {err}\n```\n\n"
1243
- f"```\n{m.group(1).rstrip()}\n```")
1244
- lines = art.splitlines()
1245
- width = max((len(l) for l in lines), default=0)
1246
- if ONLY:
1247
- # Raw, unwrapped, for a pager that can scroll sideways.
1248
- return "\0DIAGRAM%d\0%s\0" % (n, art) if n == ONLY else ""
1249
- if width > BUDGET:
1250
- # glow wraps whatever it renders, and wrapped box-drawing art is
1251
- # noise. Emit a marker instead and splice the raw art back in after
1252
- # glow has run, so prose wraps to the pane and the diagram does not.
1253
- run = os.environ.get("ACSTACK_RUN") or str(os.getpid())
1254
- # /tmp, not gettempdir(): the shell trap and the splicer both look
1255
- # there, and a stray $TMPDIR would hide the art from either.
1256
- art_path = pathlib.Path(f"/tmp/acstack-md-art.{run}.{n}")
1257
- art_path.write_text(art + "\n")
1258
- # The arrow keys belong to the pager. A preview pane has none, and
1259
- # opening the file is what draws the diagram there.
1260
- hint = "open it full width" if PREVIEW else "pans sideways with the arrow keys"
1261
- return f"\n```\n\x00ACSTACKART{n}\x00 {width} cols, {hint}\n```"
1262
- return "```\n" + art + "\n```"
1263
-
1264
- out = re.sub(r"```mermaid\n(.*?)```", repl, text, flags=re.S)
1265
- if ONLY:
1266
- hit = re.search(r"\0DIAGRAM(\d+)\0(.*?)\0", out, re.S)
1267
- sys.stdout.write(hit.group(2) if hit else f"no diagram {ONLY} in this file\n")
1268
- else:
1269
- sys.stdout.write(out)
1270
- PY
1271
-
1272
- if [ "$ONLY" != "0" ]; then
1273
- # -S chops instead of wrapping: arrow keys scroll sideways.
1274
- emit /tmp/acstack-md.$$.md less -SR
1275
- elif command -v glow >/dev/null 2>&1; then
1276
- # glow drops all styling when stdout is not a terminal, and its output has
1277
- # to be captured to splice the diagrams into, so it runs under a pty.
1278
- ACSTACK_COLS="$COLS" ACSTACK_STYLE="${ACSTACK_MD_STYLE:-}" \
1279
- python3 - /tmp/acstack-md.$$.md /tmp/acstack-md-out.$$.md <<'PY'
1280
- import fcntl, os, pathlib, pty, re, struct, subprocess, sys, termios
1281
- src, dst = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
1282
- cols = int(os.environ.get("ACSTACK_COLS") or 80)
1283
- # Leave glow on "auto": under a pty it picks its full palette. Pinning a
1284
- # named style here gives a noticeably flatter one.
1285
- style = os.environ.get("ACSTACK_STYLE") or ""
1286
-
1287
- cmd = ["glow", "-w", str(cols - 2)] + (["--style", style] if style else []) + [str(src)]
1288
- master, slave = pty.openpty()
1289
- # glow reads the width from the pty, not just -w, so set it to match.
1290
- fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 200, min(cols, 65535), 0, 0))
1291
- proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=slave, stderr=subprocess.DEVNULL)
1292
- os.close(slave)
1293
- chunks = []
1294
- while True:
1295
- try:
1296
- b = os.read(master, 65536)
1297
- except OSError:
1298
- break
1299
- if not b:
1300
- break
1301
- chunks.append(b)
1302
- os.close(master); proc.wait()
1303
- raw = b"".join(chunks)
1304
- text = raw.decode("utf-8", "replace").replace("\r\n", "\n")
1305
- # A pty makes glow probe the terminal for its background colour. Those replies
1306
- # are meant for a real terminal, not a file, so drop them.
1307
- text = re.sub(r"\x1b\][01][01];\?(\x07|\x1b\\)", "", text)
1308
- text = re.sub(r"\x1b\[6n", "", text)
1309
- dst.write_text(text)
1310
- PY
1311
- splice /tmp/acstack-md-out.$$.md
1312
- emit /tmp/acstack-md-out.$$.md ${PAGER:-less -SR}
1313
- else
1314
- splice /tmp/acstack-md.$$.md
1315
- emit /tmp/acstack-md.$$.md ${PAGER:-less -R}
1316
- fi
1317
- H
1318
- chmod +x "$BIN/acstack-md"
1319
-
1320
- cat > "$BIN/acstack-view" <<'H'
1321
- #!/usr/bin/env bash
1322
- # View one file, picked by what the file is.
1323
- #
1324
- # acstack-view README.md rendered markdown, mermaid diagrams included
1325
- # acstack-view src/main.rs yazi, opened with the cursor on that file
1326
- #
1327
- # tuicr's <leader>v hands us the file under its cursor. Everything this
1328
- # dispatches to is a terminal program, so `q` hands the screen back to tuicr
1329
- # the same way a terminal editor does - which is the whole reason the choice
1330
- # lives in a script rather than in tuicr: changing it is an edit, not a
1331
- # rebuild.
1332
- # Installed by acstack.
1333
- set -uo pipefail
1334
-
1335
- FILE="${1:-}"
1336
- [ -n "$FILE" ] || { echo "usage: acstack-view <file>" >&2; exit 2; }
1337
- [ -e "$FILE" ] || { echo "acstack-view: no such file: $FILE" >&2; exit 1; }
1338
-
1339
- # Markdown is worth rendering rather than listing: acstack-md draws mermaid
1340
- # diagrams as text, and offers --serve for a browser when a diagram needs to
1341
- # be rendered properly.
1342
- case "$FILE" in
1343
- *.md|*.MD|*.markdown|*.Markdown|*.mdown|*.mkd)
1344
- command -v acstack-md >/dev/null 2>&1 && exec acstack-md "$FILE"
1345
- ;;
1346
- esac
1347
-
1348
- # yazi opens on the file's own directory with the cursor on it, so the preview
1349
- # pane shows the file and the tree is right there to move around in.
1350
- command -v yazi >/dev/null 2>&1 && exec yazi "$FILE"
1351
-
1352
- # Neither installed: a pager still beats nothing, and -R keeps any colour.
1353
- exec ${PAGER:-less -R} "$FILE"
1354
- H
1355
- chmod +x "$BIN/acstack-view"
1356
-
1357
- cat > "$BIN/acstack-popup" <<'H'
1358
- #!/bin/sh
1359
- # Run a herdr popup's command with this folder first on PATH.
1360
- #
1361
- # acstack-popup <command> [args...]
1362
- #
1363
- # herdr runs a popup with its server's environment, not a login shell. A
1364
- # server started by `herdr --remote` has the sshd default PATH, which lacks
1365
- # this folder, so a bare popup target exits 127. apply calls this helper by
1366
- # absolute path, and it finds its folder from $0. herdr and harlequin always
1367
- # link into ~/.local/bin, so that goes on PATH too. The target and every tool
1368
- # it starts then resolve whatever PATH the server has. Installed by acstack.
1369
- PATH="$(dirname "$0"):$HOME/.local/bin:$PATH"; export PATH
1370
- exec "$@"
1371
- H
1372
- chmod +x "$BIN/acstack-popup"
1373
-
1374
- cat > "$BIN/acstack-herdr-tab" <<'H'
1375
- #!/usr/bin/env bash
1376
- # Run a command in a new herdr tab, beside the pane the key was pressed in.
1377
- #
1378
- # acstack-herdr-tab <label> <command> [args...]
1379
- #
1380
- # herdr has no type = "tab" custom command, and its tab.create API launches a
1381
- # shell rather than a command, so the tab is created first and the command
1382
- # sent into it with `herdr pane run`.
1383
- #
1384
- # The target comes from `herdr pane current`, not from HERDR_* variables: a
1385
- # type = "shell" binding runs detached with none of them set, and reading the
1386
- # environment sent every one of these commands off to run with no terminal.
1387
- # Asked without that environment, herdr reports the focused pane, which is
1388
- # exactly the one the key was pressed in. Installed by acstack.
1389
- set -uo pipefail
1390
-
1391
- label="${1:?usage: acstack-herdr-tab <label> <command> [args...]}"; shift
1392
- [ $# -gt 0 ] || { echo "acstack-herdr-tab: no command given" >&2; exit 1; }
1393
-
1394
- command -v herdr >/dev/null 2>&1 || exec "$@"
1395
-
1396
- cur=$(herdr pane current 2>/dev/null) || exec "$@"
1397
- read -r ws cwd <<<"$(printf '%s' "$cur" | python3 -c '
1398
- import json, sys
1399
- p = json.load(sys.stdin).get("result", {}).get("pane", {})
1400
- print(p.get("workspace_id") or "", p.get("foreground_cwd") or p.get("cwd") or "")
1401
- ' 2>/dev/null)"
1402
- [ -n "${ws:-}" ] || exec "$@"
1403
-
1404
- resp=$(herdr tab create --workspace "$ws" ${cwd:+--cwd "$cwd"} --label "$label" --focus 2>/dev/null) \
1405
- || exec "$@"
1406
- pane=$(printf '%s' "$resp" | python3 -c \
1407
- 'import json,sys; print(json.load(sys.stdin)["result"]["root_pane"]["pane_id"])' 2>/dev/null)
1408
- [ -n "$pane" ] || exec "$@"
1409
-
1410
- # herdr hosts a type = "shell" command in a real pane for as long as the
1411
- # process lives, so anything slow here shows up as a stray terminal in the
1412
- # current tab. Hand the wait to a detached child and return immediately: the
1413
- # host pane then lasts only as long as the tab.create call.
1414
- #
1415
- # The settle is because the new tab's shell may not have drawn its prompt yet.
1416
- # The pty buffers input, so it is belt and braces, not a correctness need.
1417
- #
1418
- # `herdr pane run` hands the string to a shell, so each argument is quoted on
1419
- # the way in. A bare "$*" loses any file path holding a space.
1420
- printf -v cmd '%q ' "$@"
1421
- ( sleep 0.3; herdr pane run "$pane" "$cmd" >/dev/null 2>&1 ) </dev/null >/dev/null 2>&1 &
1422
- disown 2>/dev/null || true
1423
- exit 0
1424
- H
1425
- chmod +x "$BIN/acstack-herdr-tab"
1426
-
1427
- cat > "$BIN/acstack-edit" <<'H'
1428
- #!/usr/bin/env bash
1429
- # Open one file in its own herdr tab, for tools that shell out to $EDITOR.
1430
- #
1431
- # acstack-edit <file>
1432
- #
1433
- # diffnav's "o" key runs $EDITOR inside diffnav's own pane. For alt+f and alt+x
1434
- # that pane is a herdr popup, and a popup cannot host a second full-screen app,
1435
- # so the editor never draws and the key looks dead. This gives the file its own
1436
- # herdr tab and returns at once, which leaves the diff untouched behind the
1437
- # popup. Close the popup to reach the tab.
1438
- #
1439
- # ACSTACK_EDIT_EDITOR names the real editor: whatever set this script as
1440
- # $EDITOR has already taken that variable.
1441
- #
1442
- # acstack-herdr-tab does the work. It finds the pane behind the popup, creates
1443
- # the tab, and runs the editor in it, and it runs the editor in place when
1444
- # there is no herdr to ask. Installed by acstack.
1445
- set -uo pipefail
1446
-
1447
- ED="${ACSTACK_EDIT_EDITOR:-nvim}"
1448
- case "$ED" in *acstack-edit) ED=nvim ;; esac # never call itself
1449
- FILE="${1:?usage: acstack-edit <file>}"
1450
-
1451
- command -v acstack-herdr-tab >/dev/null 2>&1 || exec "$ED" "$FILE"
1452
- exec acstack-herdr-tab "$(basename "$FILE")" "$ED" "$FILE"
1453
- H
1454
- chmod +x "$BIN/acstack-edit"
1455
-
1456
- cat > "$BIN/acstack-herdr-renumber" <<'H'
1457
- #!/usr/bin/env python3
1458
- """Make the herdr sidebar numbers match prefix+1..9.
1459
-
1460
- herdr draws a workspace's position in its stored list, but prefix+N targets the
1461
- row's position in the grouped sidebar, where worktrees sit indented under their
1462
- parent repo. The two agree until you create or tear down a worktree: new ones
1463
- append to the end of the list but appear mid-sidebar under their parent, so the
1464
- numbers you read stop being the numbers you press.
1465
-
1466
- This rewrites the stored order to match the grouped order, so both agree again.
1467
- Run it after adding or removing a worktree. Safe to run repeatedly. Installed
1468
- by acstack.
1469
- """
1470
- import json
1471
- import os
1472
- import socket
1473
- import sys
1474
-
1475
-
1476
- def socket_path():
1477
- override = os.environ.get("HERDR_SOCKET_PATH")
1478
- if override:
1479
- return override
1480
- config = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
1481
- return os.path.join(config, "herdr", "herdr.sock")
1482
-
1483
-
1484
- def call(method, params=None):
1485
- try:
1486
- conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
1487
- conn.connect(socket_path())
1488
- except OSError as err:
1489
- sys.exit(f"acstack-herdr-renumber: no herdr server at {socket_path()} ({err})")
1490
- with conn:
1491
- conn.sendall((json.dumps({"id": "renumber", "method": method,
1492
- "params": params or {}}) + "\n").encode())
1493
- buf = b""
1494
- while not buf.endswith(b"\n"):
1495
- chunk = conn.recv(65536)
1496
- if not chunk:
1497
- break
1498
- buf += chunk
1499
- lines = buf.decode().strip().splitlines()
1500
- if not lines:
1501
- sys.exit(f"acstack-herdr-renumber: herdr closed the connection during {method}")
1502
- reply = json.loads(lines[0])
1503
- if "error" in reply:
1504
- sys.exit(f"acstack-herdr-renumber: {method} failed: {reply['error']}")
1505
- return reply
1506
-
1507
-
1508
- def workspaces():
1509
- return call("workspace.list")["result"]["workspaces"]
1510
-
1511
-
1512
- def grouped_order(spaces):
1513
- """The order the sidebar draws: every repo's rows contiguous, the repo
1514
- checkout above the worktrees indented under it, stored order within each
1515
- of those two parts. A group sits where its repo checkout sits, not where
1516
- its first row happens to sit, so a worktree cannot drag its parent up."""
1517
- groups, anchors = {}, {}
1518
- for position, space in enumerate(spaces):
1519
- tree = space.get("worktree") or {}
1520
- root = tree.get("repo_root") or space["workspace_id"]
1521
- groups.setdefault(root, []).append((bool(tree.get("is_linked_worktree")),
1522
- position, space["workspace_id"]))
1523
- if not tree.get("is_linked_worktree") and root not in anchors:
1524
- anchors[root] = position
1525
- # A group of nothing but worktrees, its repo checkout never opened, anchors
1526
- # on its first row instead.
1527
- for root, members in groups.items():
1528
- anchors.setdefault(root, min(position for _, position, _ in members))
1529
- order = []
1530
- for root in sorted(groups, key=lambda key: anchors[key]):
1531
- order.extend(wid for _, _, wid in sorted(groups[root]))
1532
- return order
1533
-
1534
-
1535
- def main():
1536
- before = workspaces()
1537
- target = grouped_order(before)
1538
- if [space["workspace_id"] for space in before] == target:
1539
- print("already aligned")
1540
- else:
1541
- for index, workspace_id in enumerate(target):
1542
- call("workspace.move", {"workspace_id": workspace_id, "insert_index": index})
1543
- for space in workspaces():
1544
- indent = " " if (space.get("worktree") or {}).get("is_linked_worktree") else ""
1545
- print(f"{space['number']:>3} {indent}{space['label']}")
1546
-
1547
-
1548
- if __name__ == "__main__":
1549
- main()
1550
- H
1551
- chmod +x "$BIN/acstack-herdr-renumber"
1552
-
1553
- cat > "$BIN/acstack-clip" <<'H'
1554
- #!/usr/bin/env bash
1555
- # Put text on the clipboard of the machine your terminal is on.
1556
- #
1557
- # acstack-clip "text" copy the arguments
1558
- # ... | acstack-clip copy stdin
1559
- # acstack-clip --out print what was copied last
1560
- #
1561
- # A VM reached over SSH has no display and no clipboard of its own, so
1562
- # xclip and friends have nothing to talk to. OSC 52 is the escape sequence
1563
- # that asks the terminal at the far end - Windows Terminal, iTerm2, kitty -
1564
- # to put text on its own clipboard. It is only bytes in the terminal stream,
1565
- # so it crosses SSH for free, and herdr forwards it from a pane to whatever
1566
- # terminal is hosting it.
1567
- #
1568
- # Reading back is not symmetrical. Windows Terminal refuses OSC 52 reads on
1569
- # purpose, so --out replays a local cache instead. Installed by acstack.
1570
- set -uo pipefail
1571
-
1572
- CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/acstack/clipboard"
1573
-
1574
- # A real clipboard beats an escape sequence when one exists. Anything sitting
1575
- # in this script's own directory is skipped, so the xclip shim installed
1576
- # beside it can never call back into here in a loop.
1577
- native() { # native <tool>; prints the path of a real one, if any
1578
- local tool="$1" self candidate
1579
- self="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
1580
- while IFS= read -r candidate; do
1581
- [ "$(cd "$(dirname "$candidate")" 2>/dev/null && pwd)" = "$self" ] && continue
1582
- printf '%s' "$candidate"; return 0
1583
- done < <(type -ap "$tool" 2>/dev/null)
1584
- return 1
1585
- }
1586
-
1587
- has_display() { [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; }
1588
-
1589
- if [ "${1:-}" = "--out" ] || [ "${1:-}" = "-o" ]; then
1590
- if has_display; then
1591
- for tool in wl-paste xclip xsel; do
1592
- real="$(native "$tool")" || continue
1593
- case "$tool" in
1594
- wl-paste) exec "$real" --no-newline ;;
1595
- xclip) exec "$real" -out -selection clipboard ;;
1596
- xsel) exec "$real" --output --clipboard ;;
1597
- esac
1598
- done
1599
- fi
1600
- [ -f "$CACHE" ] || { echo "acstack-clip: nothing copied yet" >&2; exit 1; }
1601
- cat "$CACHE"
1602
- exit 0
1603
- fi
1604
-
1605
- if [ $# -gt 0 ]; then text="$*"; else text="$(cat)"; fi
1606
-
1607
- mkdir -p "$(dirname "$CACHE")"
1608
- printf '%s' "$text" > "$CACHE"
1609
- chmod 600 "$CACHE" 2>/dev/null
1610
-
1611
- if has_display; then
1612
- for tool in wl-copy xclip xsel; do
1613
- real="$(native "$tool")" || continue
1614
- case "$tool" in
1615
- wl-copy) printf '%s' "$text" | "$real" && exit 0 ;;
1616
- xclip) printf '%s' "$text" | "$real" -in -selection clipboard && exit 0 ;;
1617
- xsel) printf '%s' "$text" | "$real" --input --clipboard && exit 0 ;;
1618
- esac
1619
- done
1620
- fi
1621
-
1622
- b64="$(printf '%s' "$text" | base64 | tr -d '\n')"
1623
- # 74994 bytes is the ceiling tmux puts on an OSC 52 payload, and the lowest
1624
- # of anything in this path. Past it the sequence is dropped without a word,
1625
- # so say so rather than report a copy that did not happen.
1626
- if [ ${#b64} -gt 74994 ]; then
1627
- echo "acstack-clip: too large for the terminal clipboard (${#b64} bytes encoded)" >&2
1628
- exit 1
1629
- fi
1630
-
1631
- esc=$'\033]52;c;'"$b64"$'\a'
1632
- # tmux drops escape sequences it does not recognise unless they are wrapped
1633
- # for passthrough. herdr needs no wrapping - it forwards OSC 52 itself.
1634
- if [ -n "${TMUX:-}" ]; then
1635
- esc=$'\033Ptmux;'"${esc//$'\033'/$'\033\033'}"$'\033\\'
1636
- fi
1637
-
1638
- # The controlling terminal, not stdout. Callers are usually TUIs that have
1639
- # redirected both streams, and OSC 52 has to reach the terminal itself.
1640
- if { printf '%s' "$esc" > /dev/tty; } 2>/dev/null; then exit 0; fi
1641
- printf '%s' "$esc" >&2
1642
- H
1643
- chmod +x "$BIN/acstack-clip"
1644
-
1645
- cat > "$BIN/acstack-open" <<'H'
1646
- #!/usr/bin/env bash
1647
- # Open a URL from a machine that has no browser of its own.
1648
- #
1649
- # acstack-open https://github.com/owner/repo/pull/1
1650
- # acstack-open --why which route this machine will take, without taking it
1651
- #
1652
- # gh-dash binds o to this. It is bound as a keybinding rather than left to
1653
- # $BROWSER because gh-dash runs $BROWSER with its output discarded and the
1654
- # dashboard still drawn: a route that needs to say anything, or to hand you a
1655
- # link to click, has nowhere to put it. As a keybinding gh-dash steps aside
1656
- # and gives us the terminal.
1657
- #
1658
- # In order: an explicit override, a desktop on this machine, WSL, the opener
1659
- # service on the laptop, the Playwriter bridge to Chrome on the laptop, and
1660
- # last a link you can click.
1661
- # Installed by acstack.
1662
- set -uo pipefail
1663
-
1664
- LOG="${XDG_CACHE_HOME:-$HOME/.cache}/acstack/open.log"
1665
- # An ambient $AGENT_BROWSER_CDP is a hint, never the thing we rely on. See
1666
- # the bridge branch below for what happened when it was.
1667
- BRIDGE="${ACSTACK_BRIDGE_URL:-${AGENT_BROWSER_CDP:-http://127.0.0.1:19988}}"
1668
- OPENER="${ACSTACK_OPENER_URL:-http://127.0.0.1:19989}"
1669
- # The opener refuses anything without this, so its absence is the same as the
1670
- # opener being down - which is exactly how the route probe treats it.
1671
- OPENER_TOKEN="${XDG_CONFIG_HOME:-$HOME/.config}/acstack/opener.token"
1672
-
1673
- WHY=0
1674
- [ "${1:-}" = "--why" ] && { WHY=1; shift; }
1675
- URL="${1:-}"
1676
- [ -n "$URL" ] || [ "$WHY" = 1 ] \
1677
- || { echo "usage: acstack-open [--why] <url>" >&2; exit 2; }
1678
-
1679
- log() { # the only record of what happened when the caller discards output
1680
- mkdir -p "$(dirname "$LOG")"
1681
- printf '%s %s\n' "$(date -Is)" "$*" >> "$LOG"
1682
- if [ "$(wc -l < "$LOG" 2>/dev/null || echo 0)" -gt 400 ]; then
1683
- tail -n 200 "$LOG" > "$LOG.trim" && mv "$LOG.trim" "$LOG"
1684
- fi
1685
- }
1686
-
1687
- note() { # say something a suspended TUI cannot print for us
1688
- command -v herdr >/dev/null 2>&1 \
1689
- && herdr notification show "$1" --body "$2" >/dev/null 2>&1
1690
- return 0
1691
- }
1692
63
 
1693
- # Not [ -t 1 ]: route() runs inside a command substitution for --why, which
1694
- # makes stdout a pipe even when the terminal is right there.
1695
- has_tty() { { : > /dev/tty; } 2>/dev/null; }
1696
64
 
1697
- # The relay answering on its HTTP port is not the same as the browser being
1698
- # reachable through it. agent-browser quietly launches a headless Chrome on
1699
- # this machine when it cannot attach, and that Chrome navigates happily and
1700
- # shows nobody anything - which is how o came to report a page it had opened
1701
- # where no one could see it. So ask what is actually on the far end.
1702
- #
1703
- # /json/list is the Playwriter extension's own answer to that question. The
1704
- # extension attaches per tab, so `[]` means it is armed on nothing and there
1705
- # is no browser to drive, however healthy the tunnel underneath looks.
1706
- #
1707
- # This used to gate on $HOME/.agent-browser/default.sock existing first, on
1708
- # the reasoning that probing starts a browser as a side effect. It does not:
1709
- # curl starts nothing. What that gate did do was make the bridge unreachable,
1710
- # because nothing on this machine creates that socket until an agent-browser
1711
- # session is already running - so a perfectly healthy bridge still fell
1712
- # through to the link route. Asking the relay is both safer and correct.
1713
- bridge_attached() {
1714
- [ "${ACSTACK_OPEN_BRIDGE:-auto}" != "off" ] || return 1
1715
- # agent-browser has to exist to take the route, but is never run to decide
1716
- # it: deciding must cost one round trip, not a browser launch.
1717
- command -v agent-browser >/dev/null 2>&1 || return 1
1718
- # Seconds, not tens of them: the tunnel stays bound on this side after the
1719
- # relay at the far end stops answering, so an unhealthy bridge connects and
1720
- # then hangs rather than refusing. Every one of those seconds is spent in
1721
- # front of someone who just pressed a key.
1722
- local targets
1723
- targets=$(curl -fsS --connect-timeout 1 -m 2 "$BRIDGE/json/list" 2>/dev/null) || return 1
1724
- # Whitespace-only and [] both mean armed on no tab.
1725
- case "$(printf '%s' "$targets" | tr -d '[:space:]')" in
1726
- ''|'[]') return 1 ;;
1727
- esac
1728
- return 0
1729
- }
1730
-
1731
- # Same discipline as bridge_attached: ask the far end rather than believing
1732
- # anything local. A token on disk proves nothing about a service being up, and
1733
- # a service being up proves nothing without the token it will demand - so the
1734
- # probe carries the token and the route is only taken if that round trip
1735
- # answered.
1736
- opener_ready() {
1737
- [ "${ACSTACK_OPEN_OPENER:-auto}" != "off" ] || return 1
1738
- local tok
1739
- tok=$(cat "$OPENER_TOKEN" 2>/dev/null) || return 1
1740
- [ -n "$tok" ] || return 1
1741
- # Seconds, not tens of them - someone just pressed a key and is watching.
1742
- curl -fsS --connect-timeout 1 -m 2 -H "X-ACStack-Token: $tok" \
1743
- "$OPENER/health" >/dev/null 2>&1
1744
- }
1745
-
1746
- route() { # the route this machine will take, decided without taking it
1747
- [ -n "${ACSTACK_OPEN_CMD:-}" ] && { echo "command"; return; }
1748
- [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && command -v xdg-open >/dev/null 2>&1 \
1749
- && { echo "xdg-open"; return; }
1750
- local opener
1751
- for opener in wslview wsl-open explorer.exe; do
1752
- command -v "$opener" >/dev/null 2>&1 && { echo "$opener"; return; }
1753
- done
1754
- # Ahead of the bridge deliberately. The bridge drives a browser for an
1755
- # agent: it attaches per tab, only after someone clicks the extension icon,
1756
- # and it does so in a Chrome profile that is not the default browser. The
1757
- # opener needs no click and reaches the browser the user actually uses. See
1758
- # docs/adr/0006-document-opener-service.md.
1759
- opener_ready && { echo "opener"; return; }
1760
- bridge_attached && { echo "bridge"; return; }
1761
- has_tty && { echo "link"; return; }
1762
- echo "clipboard"
1763
- }
1764
-
1765
- # A clickable link, on the terminal you are actually sitting at. OSC 8 is a
1766
- # hyperlink the terminal draws itself, so ctrl+click reaches the browser on
1767
- # your own machine with nothing installed anywhere in between. The bare URL
1768
- # on the next line covers terminals that ignore OSC 8, and the clipboard
1769
- # copy covers not wanting to click at all.
1770
- link_screen() {
1771
- printf '%s' "$URL" | acstack-clip
1772
- {
1773
- printf '\n Open on GitHub\n\n '
1774
- printf '\033]8;;%s\033\\%s\033]8;;\033\\\n' "$URL" "$URL"
1775
- printf ' ctrl+click the link, or paste it - it is on your clipboard\n\n'
1776
- printf ' Press any key to go back.'
1777
- } > /dev/tty
1778
- read -rsn1 < /dev/tty
1779
- printf '\n' > /dev/tty
1780
- }
1781
-
1782
- ROUTE="$(route)"
1783
- [ "$WHY" = 1 ] && { echo "$ROUTE"; exit 0; }
1784
-
1785
- case "$ROUTE" in
1786
- command)
1787
- log "override: $ACSTACK_OPEN_CMD $URL"
1788
- exec ${ACSTACK_OPEN_CMD} "$URL"
1789
- ;;
1790
- xdg-open)
1791
- # $BROWSER is cleared first: xdg-open reads it too, and $BROWSER may well
1792
- # be pointing back here, which would loop.
1793
- log "xdg-open: $URL"
1794
- BROWSER= exec xdg-open "$URL"
1795
- ;;
1796
- explorer.exe)
1797
- # explorer.exe reports failure even when it opened the page. Ignore it.
1798
- log "explorer.exe: $URL"
1799
- explorer.exe "$URL" >/dev/null 2>&1
1800
- exit 0
1801
- ;;
1802
- wslview|wsl-open)
1803
- log "$ROUTE: $URL"
1804
- exec "$ROUTE" "$URL"
1805
- ;;
1806
- opener)
1807
- # One POST and the laptop opens it in whatever it considers the default
1808
- # browser. No tab to arm, no extension, no CDP session to contend for.
1809
- #
1810
- # A URL carrying a double quote or a backslash would break out of the JSON
1811
- # string it is about to become. Rather than escape it, fall through to the
1812
- # link screen: those characters are not legal in a URL unescaped, so a URL
1813
- # holding one is malformed and worth showing a human rather than sending.
1814
- tok=$(cat "$OPENER_TOKEN" 2>/dev/null || true)
1815
- case "$URL" in
1816
- *'"'*|*'\\'*) tok="" ;;
1817
- esac
1818
- if [ -n "$tok" ] && curl -fsS --connect-timeout 2 -m 10 -X POST \
1819
- -H "X-ACStack-Token: $tok" -H 'Content-Type: application/json' \
1820
- --data "$(printf '{"url":"%s"}' "$URL")" \
1821
- "$OPENER/open" 2>/dev/null | tr -d '[:space:]' | grep -q '"opened":true'; then
1822
- log "opener $OPENER: $URL"
1823
- exit 0
1824
- fi
1825
- log "opener $OPENER did not open it, handing over the link: $URL"
1826
- if has_tty; then link_screen; exit 0; fi
1827
- ;;
1828
- bridge)
1829
- # Name the endpoint rather than inheriting one. Leaning on an ambient
1830
- # $AGENT_BROWSER_CDP made this launch a headless Chrome on the VM when
1831
- # run from a herdr popup, which reported success and opened nothing
1832
- # anybody could see.
1833
- #
1834
- # The default session, deliberately: the relay takes one CDP client at a
1835
- # time, so a session of our own cannot connect while an agent holds it.
1836
- # That also means the new tab becomes the active one for any agent
1837
- # driving that browser. ACSTACK_OPEN_BRIDGE=off trades the one-key open
1838
- # for never touching it.
1839
- if timeout 20 agent-browser --cdp "$BRIDGE" tab new >/dev/null 2>&1 \
1840
- && timeout 30 agent-browser --cdp "$BRIDGE" open "$URL" >/dev/null 2>&1; then
1841
- log "bridge $BRIDGE: $URL"
1842
- exit 0
1843
- fi
1844
- log "bridge $BRIDGE unavailable, handing over the link: $URL"
1845
- if has_tty; then link_screen; exit 0; fi
1846
- ;;
1847
- esac
1848
-
1849
- [ "$ROUTE" = link ] && { log "link: $URL"; link_screen; exit 0; }
1850
-
1851
- # No terminal to draw on and no browser to reach. The clipboard is still one
1852
- # paste from the browser on the machine you are sitting at, which beats
1853
- # nothing, but report failure anyway: a silent success here is what left
1854
- # gh-dash saying "Opened in browser" about a page that never opened.
1855
- if printf '%s' "$URL" | acstack-clip; then
1856
- log "copied, not opened: $URL"
1857
- note "URL copied, not opened" "No browser reachable from here. Paste: $URL"
1858
- exit 3
1859
- fi
1860
- log "no route: $URL"
1861
- note "Cannot open URL" "$URL"
1862
- exit 1
1863
- H
1864
- chmod +x "$BIN/acstack-open"
1865
-
1866
- cat > "$BIN/acstack-bridge" <<'H'
1867
- #!/usr/bin/env bash
1868
- # Look at the Playwriter bridge from the dev machine, and fix it where that is
1869
- # possible from here.
1870
- #
1871
- # acstack-bridge status what is up, on both machines
1872
- # acstack-bridge fix [what] restart relay | tunnel | both (default both)
1873
- #
1874
- # Restarting either is a Windows scheduled task, so this machine cannot do it
1875
- # directly. It asks the opener, which runs over there and can. When the opener
1876
- # is not reachable either, it prints the handoff block to paste into a
1877
- # PowerShell on the laptop instead - the same block the bridge skill defines.
1878
- #
1879
- # What it still cannot do is arm the extension. That is a click inside a
1880
- # third-party extension and nothing on either machine can press it.
1881
- # Installed by acstack.
1882
- set -uo pipefail
1883
-
1884
- BRIDGE="${ACSTACK_BRIDGE_URL:-http://127.0.0.1:19988}"
1885
- OPENER="${ACSTACK_OPENER_URL:-http://127.0.0.1:19989}"
1886
- OPENER_TOKEN="${XDG_CONFIG_HOME:-$HOME/.config}/acstack/opener.token"
1887
- FREE_PORT="${ACSTACK_FREE_PORT:-$HOME/.claude/acstack/bin/vm-free-port.sh}"
1888
-
1889
- CMD="${1:-status}"
1890
- WHAT="${2:-both}"
1891
-
1892
- tok() { cat "$OPENER_TOKEN" 2>/dev/null; }
1893
-
1894
- clear_port() { # clear_port <port> - let go of a forward this machine still holds
1895
- # A tunnel that will not rebind is usually held from this side: the VM's sshd
1896
- # still owns the forward channel of a session that dropped. Restarting the
1897
- # task on the laptop then fails exactly the way it failed before, because
1898
- # nothing here let go of the port. So this runs first.
1899
- #
1900
- # The clearer ships with acstack and may simply not be installed on a machine
1901
- # that got the terminal workspace some other way. Worth saying, but not a
1902
- # reason to refuse the restart, which may be all that was needed.
1903
- if [ ! -f "$FREE_PORT" ]; then
1904
- echo "no port clearer at $FREE_PORT - asking for the restart without freeing $1 first." >&2
1905
- return 0
1906
- fi
1907
- #
1908
- # A clearer that predates --port is the awkward case in between: it exists,
1909
- # so the missing-file warning never fires, but it only knows how to free
1910
- # 19988. Called bare it would clear the bridge port twice, never touch 19989,
1911
- # and hand back two successes - so the caller would believe both ports were
1912
- # freed. Report the version and clear nothing; guessing is worse than saying.
1913
- # It announces its age two ways: the flag is nowhere in its text, or it takes
1914
- # the flag, does not recognise the argument, and exits 64 - the usage code,
1915
- # which is not "the port is stuck" and is not worth a retry without the flag.
1916
- local older="is older than this branch, so it cannot free $1 on request."
1917
- local fix="Update acstack on this machine to refresh it. Asking for the restart anyway."
1918
- if ! grep -q -- '--port' "$FREE_PORT" 2>/dev/null; then
1919
- echo "$FREE_PORT $older It has no --port support at all. $fix" >&2
1920
- return 0
1921
- fi
1922
- local rc=0
1923
- bash "$FREE_PORT" --port "$1" >/dev/null 2>&1 || rc=$?
1924
- if [ "$rc" = "64" ]; then
1925
- echo "$FREE_PORT $older It refused --port with the usage code 64. $fix" >&2
1926
- elif [ "$rc" != "0" ]; then
1927
- echo "$FREE_PORT could not free $1 - asking for the restart anyway." >&2
1928
- fi
1929
- }
1930
-
1931
- opener_call() { # opener_call <method> <path> [data]
1932
- local t; t=$(tok) || return 1
1933
- [ -n "$t" ] || return 1
1934
- if [ "$1" = "POST" ]; then
1935
- curl -fsS --connect-timeout 2 -m 40 -X POST -H "X-ACStack-Token: $t" \
1936
- -H 'Content-Type: application/json' --data "${3:-{\}}" "$OPENER$2" 2>/dev/null
1937
- else
1938
- curl -fsS --connect-timeout 2 -m 10 -H "X-ACStack-Token: $t" "$OPENER$2" 2>/dev/null
1939
- fi
1940
- }
1941
-
1942
- handoff() { # what this machine cannot do, in the shape the other side expects
1943
- # Unquoted, because $1 names what is being asked for - so every PowerShell $
1944
- # inside has to be escaped or the shell eats it. A handoff block is pasted
1945
- # verbatim into a shell on the other machine, so a mangled one is worse than
1946
- # no block at all.
1947
- cat >&2 <<HANDOFF
1948
-
1949
- --- BEGIN VM-SIDE HANDOFF (to Windows agent) ---
1950
- Context: the bridge needs $1 on the laptop, and the opener on 19989 is not
1951
- reachable from this machine either, so nothing here can do it.
1952
-
1953
- Run on the Windows laptop:
1954
- Stop-ScheduledTask -TaskName 'Playwriter-*'
1955
- Start-ScheduledTask -TaskName 'Playwriter-Relay'
1956
- Start-ScheduledTask -TaskName 'Playwriter-Opener'
1957
- Get-ScheduledTask -TaskName 'Playwriter-Tunnel-*','Playwriter-OpenerTunnel-*' |
1958
- ForEach-Object { Start-ScheduledTask -TaskName \$_.TaskName }
1959
- .\doctor.ps1
1960
-
1961
- Expected result:
1962
- doctor.ps1 exits with all green rows, including "Relay listening on 19988"
1963
- and "Opener listening on 19989", and no tunnel-*.stuck markers.
1964
-
1965
- Report back:
1966
- The doctor.ps1 output (paste the table).
1967
- --- END VM-SIDE HANDOFF ---
1968
- HANDOFF
1969
- }
1970
-
1971
- case "$CMD" in
1972
- status)
1973
- # This machine's own view first: these say whether the tunnels arrived,
1974
- # which is the half the laptop cannot see.
1975
- if curl -fsS --connect-timeout 1 -m 3 "$BRIDGE/json/version" >/dev/null 2>&1; then
1976
- armed=$(curl -fsS --connect-timeout 1 -m 3 "$BRIDGE/json/list" 2>/dev/null | tr -d '[:space:]')
1977
- case "$armed" in
1978
- ''|'[]') echo "relay: reachable, but the extension is armed on no tab" ;;
1979
- *) echo "relay: reachable, extension armed" ;;
1980
- esac
1981
- else
1982
- echo "relay: not reachable on $BRIDGE"
1983
- fi
1984
-
1985
- if health=$(opener_call GET /health); then
1986
- echo "opener: reachable - $(printf '%s' "$health" | tr -d '[:space:]')"
1987
- elif [ -z "$(tok)" ]; then
1988
- echo "opener: no token at $OPENER_TOKEN (run register-tasks.ps1 on the laptop)"
1989
- else
1990
- echo "opener: not reachable on $OPENER"
1991
- fi
1992
-
1993
- # And the laptop's view, which only the opener can fetch.
1994
- if tasks=$(opener_call GET /bridge/health); then
1995
- echo "tasks: $(printf '%s' "$tasks" | tr -d '[:space:]')"
1996
- else
1997
- echo "tasks: unknown - the opener is the only way to see them from here"
1998
- fi
1999
- echo "route: $(acstack-open --why 2>/dev/null || echo unknown)"
2000
- ;;
2001
- fix)
2002
- case "$WHAT" in
2003
- relay|tunnel|both) ;;
2004
- *) echo "usage: acstack-bridge fix [relay|tunnel|both]" >&2; exit 2 ;;
2005
- esac
2006
- # A tunnel restart is the only case where a port here is in the way. Both
2007
- # ports get cleared, because the opener's 19989 is forwarded by its own task
2008
- # and wedges on its own. fix relay touches no tunnel, so clearing a port
2009
- # there would be tearing down a working connection to fix something else.
2010
- if [ "$WHAT" != "relay" ]; then
2011
- clear_port 19988
2012
- clear_port 19989
2013
- fi
2014
- if out=$(opener_call POST /bridge/restart "$(printf '{"what":"%s"}' "$WHAT")"); then
2015
- echo "asked the laptop to restart: $WHAT"
2016
- printf '%s\n' "$out"
2017
- # Restarting is not recovering. Give the tunnels a moment to rebind, then
2018
- # say whether they actually came back rather than reporting the request.
2019
- sleep 5
2020
- rc=0
2021
- if curl -fsS --connect-timeout 1 -m 3 "$BRIDGE/json/version" >/dev/null 2>&1; then
2022
- echo "relay is answering again on $BRIDGE"
2023
- else
2024
- echo "the restart was accepted but the relay is still not answering here on 19988." >&2
2025
- echo " if this persists the port may be wedged on this machine:" >&2
2026
- echo " bash $FREE_PORT --port 19988" >&2
2027
- rc=1
2028
- fi
2029
- # Two tunnels, two verdicts. A relay that came back says nothing about
2030
- # the opener, and an opener that stayed down is the half that leaves this
2031
- # machine unable to reach a browser at all - so it is checked, and named.
2032
- if [ "$WHAT" != "relay" ]; then
2033
- if opener_call GET /health >/dev/null; then
2034
- echo "opener is answering again on $OPENER"
2035
- else
2036
- echo "the restart was accepted but the opener is still not answering here on 19989." >&2
2037
- echo " if this persists the port may be wedged on this machine:" >&2
2038
- echo " bash $FREE_PORT --port 19989" >&2
2039
- rc=1
2040
- fi
2041
- fi
2042
- exit $rc
2043
- fi
2044
- handoff "a restart of $WHAT"
2045
- exit 1
2046
- ;;
2047
- *)
2048
- echo "usage: acstack-bridge [status|fix [relay|tunnel|both]]" >&2
2049
- exit 2
2050
- ;;
2051
- esac
2052
- H
2053
- chmod +x "$BIN/acstack-bridge"
2054
-
2055
- say "helpers written: acstack-diff, acstack-dash, acstack-db, acstack-md, acstack-view"
2056
- say "remote helpers written: acstack-clip, acstack-open, acstack-bridge"
2057
- say "herdr helpers written: acstack-popup, acstack-herdr-tab, acstack-herdr-renumber, acstack-edit"
2058
- }
2059
-
2060
- apply_herdr() {
2061
- have herdr || { say "herdr not installed, skipping keymap"; return 0; }
2062
- # Every popup runs through acstack-popup. disable reaches here without
2063
- # apply, so after an upgrade the helper may not exist yet.
2064
- [ -x "$BIN/acstack-popup" ] || write_helpers
2065
- mkdir -p "$(dirname "$HERDR_CFG")"; touch "$HERDR_CFG"
2066
- local mod wt diffkey dashkey filekey filetabkey difftabkey dashtabkey
2067
- local pickkey picktabkey gitkey gittabkey dbkey dbtabkey scrollbars
2068
- local theme themeauto themelight toast labels panelsort
2069
- mod=$(cfg_get herdr.chord_modifier alt)
2070
- wt=$(cfg_get herdr.worktrees_directory "~/.herdr/worktrees")
2071
- # f, not d: Windows Terminal keeps alt+shift+d for "duplicate pane", so the
2072
- # tab half of a d pair never reaches herdr. Both diff keys sit on f instead.
2073
- diffkey=$(cfg_get diffnav.popup_key "alt+f")
2074
- dashkey=$(cfg_get gh_dash.popup_key "alt+i")
2075
- filekey=$(cfg_get yazi.popup_key "alt+y")
2076
- filetabkey=$(cfg_get yazi.tab_key "alt+shift+y")
2077
- difftabkey=$(cfg_get diffnav.tab_key "alt+shift+f")
2078
- dashtabkey=$(cfg_get gh_dash.tab_key "alt+shift+i")
2079
- pickkey=$(cfg_get diffnav.pick_key "alt+x")
2080
- picktabkey=$(cfg_get diffnav.pick_tab_key "alt+shift+x")
2081
- gitkey=$(cfg_get lazygit.popup_key "alt+g")
2082
- gittabkey=$(cfg_get lazygit.tab_key "alt+shift+g")
2083
- dbkey=$(cfg_get harlequin.popup_key "alt+q")
2084
- dbtabkey=$(cfg_get harlequin.tab_key "alt+shift+q")
2085
- # herdr counts the scrollbar column inside the pane rect but reports the
2086
- # full rect width to the program running in the pane. A full-screen app then
2087
- # writes one column more than it has, so every wrapped row starts a column
2088
- # left of the row above it. Turning the scrollbar off gives the column back.
2089
- scrollbars=$(cfg_get herdr.pane_scrollbars false)
2090
- # Four settings that are not keys. Each one is empty by default and then
2091
- # written only when the config names it, so a machine that says nothing
2092
- # keeps whatever herdr already paints. The exception is the pane label,
2093
- # which is written either way: several agents in several panes is what this
2094
- # workspace is for, and an unlabelled pane does not say which agent it holds.
2095
- theme=$(cfg_get herdr.theme.name "")
2096
- themeauto=$(cfg_get herdr.theme.auto_switch true)
2097
- themelight=$(cfg_get herdr.theme.light "")
2098
- toast=$(cfg_get herdr.toast_delivery "")
2099
- labels=$(cfg_get herdr.agent_labels_on_pane_borders true)
2100
- panelsort=$(cfg_get herdr.agent_panel_sort "")
2101
- [ "$(cfg_get diffnav.enabled true)" = "true" ] \
2102
- || { diffkey=""; difftabkey=""; pickkey=""; picktabkey=""; }
2103
- [ "$(cfg_get gh_dash.enabled true)" = "true" ] || { dashkey=""; dashtabkey=""; }
2104
- [ "$(cfg_get yazi.enabled true)" = "true" ] || { filekey=""; filetabkey=""; }
2105
- [ "$(cfg_get lazygit.enabled true)" = "true" ] || { gitkey=""; gittabkey=""; }
2106
- [ "$(cfg_get harlequin.enabled true)" = "true" ] || { dbkey=""; dbtabkey=""; }
2107
-
2108
- ACSTACK_ARGS="$mod|$wt|$diffkey|$dashkey|$filekey|$filetabkey|$difftabkey|$dashtabkey|$pickkey|$picktabkey|$gitkey|$gittabkey|$dbkey|$dbtabkey|$scrollbars|$theme|$themeauto|$themelight|$toast|$labels|$panelsort|$BEGIN|$END" ACSTACK_BIN="$BIN" py "$HERDR_CFG" <<'PY'
2109
- import os, re, sys
2110
- path = sys.argv[1]
2111
- # herdr runs a popup with its server's PATH, which can lack $BIN. Each popup
2112
- # goes through acstack-popup by absolute path, which puts $BIN back on PATH.
2113
- popup = os.path.join(os.environ["ACSTACK_BIN"], "acstack-popup")
2114
- (mod, wt, diffkey, dashkey, filekey, filetabkey, difftabkey, dashtabkey,
2115
- pickkey, picktabkey, gitkey, gittabkey, dbkey, dbtabkey, scrollbars,
2116
- theme, themeauto, themelight, toast, labels, panelsort,
2117
- begin, end) = os.environ["ACSTACK_ARGS"].split("|")
2118
-
2119
- # key, command, description, herdr custom-command type, popup size.
2120
- # type "shell" takes no size: herdr rejects width/height on a non-popup.
2121
- CUSTOM = [
2122
- (diffkey, "acstack-diff", "diff: branch vs dev", "popup", "90%"),
2123
- (difftabkey, "acstack-herdr-tab diff acstack-diff", "diff: branch vs dev (tab)", "shell", None),
2124
- (pickkey, "acstack-diff pick", "diff: pick what to compare", "popup", "90%"),
2125
- (picktabkey, "acstack-herdr-tab diffpick acstack-diff pick",
2126
- "diff: pick what to compare (tab)", "shell", None),
2127
- (dashkey, "acstack-dash", "PR dashboard", "popup", "95%"),
2128
- (dashtabkey, "acstack-herdr-tab dash acstack-dash", "PR dashboard (tab)", "shell", None),
2129
- (filekey, "yazi", "file tree", "popup", "95%"),
2130
- (filetabkey, "acstack-herdr-tab yazi yazi", "file tree (tab)", "shell", None),
2131
- (gitkey, "lazygit", "git: stage, commit, push", "popup", "90%"),
2132
- (gittabkey, "acstack-herdr-tab lazygit lazygit",
2133
- "git: stage, commit, push (tab)", "shell", None),
2134
- (dbkey, "acstack-db", "db: harlequin SQL editor", "popup", "90%"),
2135
- (dbtabkey, "acstack-herdr-tab harlequin acstack-db",
2136
- "db: harlequin SQL editor (tab)", "shell", None),
2137
- ]
2138
-
2139
- def custom_blocks():
2140
- out = []
2141
- for key, cmd, desc, typ, size in CUSTOM:
2142
- if not key:
2143
- continue
2144
- if typ == "popup":
2145
- cmd = f"{popup} {cmd}"
2146
- out += ["", "[[keys.command]]", f'key = "{key}"', f'type = "{typ}"',
2147
- f'command = "{cmd}"', f'description = "{desc}"']
2148
- if size:
2149
- out += [f'width = "{size}"', f'height = "{size}"']
2150
- return out
2151
- text = open(path).read() if os.path.isfile(path) else ""
2152
- # Drop any previous managed region so this is idempotent.
2153
- text = re.sub(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", "", text, flags=re.S)
2154
-
2155
- keys = []
2156
- if mod != "none":
2157
- m = mod
2158
- keys = [
2159
- f'focus_agent = "{m}+1..9"',
2160
- f'focus_pane_left = ["prefix+h", "{m}+h"]',
2161
- f'focus_pane_down = ["prefix+j", "{m}+j"]',
2162
- f'focus_pane_up = ["prefix+k", "{m}+k"]',
2163
- f'focus_pane_right = ["prefix+l", "{m}+l"]',
2164
- f'next_agent = "{m}+n"',
2165
- f'previous_agent = "{m}+u"',
2166
- f'next_tab = ["prefix+n", "{m}+right"]',
2167
- f'previous_tab = ["prefix+p", "{m}+left"]',
2168
- f'next_workspace = ["{m}+down"]',
2169
- f'previous_workspace = ["{m}+up"]',
2170
- # herdr puts switch_tab on prefix+1..9 by default. Move it aside so
2171
- # switch_workspace below can have the plain digits.
2172
- f'switch_tab = "prefix+{m}+1..9"',
2173
- f'toggle_sidebar = ["prefix+b", "{m}+s"]',
2174
- # g belongs to git only when lazygit is on. alt+g is lazygit and
2175
- # alt+shift+g is its tab, so goto moves off the letter on both layers:
2176
- # prefix+g one key away from alt+g reads as the same thing. With
2177
- # lazygit off, nothing wants the letter and goto keeps it.
2178
- ('goto = "prefix+t"' if gitkey else f'goto = ["prefix+g", "{m}+g"]'),
2179
- f'zoom = ["prefix+z", "{m}+z"]',
2180
- f'last_pane = "{m}+a"',
2181
- f'new_tab = ["prefix+c", "{m}+c"]',
2182
- f'new_workspace = ["prefix+shift+n", "{m}+w"]',
2183
- # Also off g, for the same reason, and back on it when lazygit is off.
2184
- (f'new_worktree = "{m}+r"' if gitkey
2185
- else f'new_worktree = ["prefix+shift+g", "{m}+r"]'),
2186
- f'split_vertical = ["prefix+v", "{m}+e"]',
2187
- f'split_horizontal = ["prefix+minus", "{m}+minus"]',
2188
- ]
2189
-
2190
- # herdr leaves switch_workspace unbound, so without this there is no way to
2191
- # reach a workspace by number at all. It is prefix-only, so it is written even
2192
- # when the chord family is off: opting out of alt chords should not cost you
2193
- # workspace numbers.
2194
- keys.append('switch_workspace = "prefix+1..9"')
2195
-
2196
- # See the comment on `scrollbars` above for what the scrollbar column costs.
2197
- # The label is written either way; the sort order only when the config names
2198
- # one, because herdr's own default is a reasonable answer and this script has
2199
- # no better one.
2200
- ui = [
2201
- "pane_scrollbars = " + ("true" if scrollbars == "true" else "false"),
2202
- "show_agent_labels_on_pane_borders = " + ("true" if labels == "true" else "false"),
2203
- ]
2204
- if panelsort:
2205
- ui.append(f'agent_panel_sort = "{panelsort}"')
2206
-
2207
- # A theme, and a toast. Both tables stay empty unless the config names a value,
2208
- # so a machine that says nothing about either keeps what herdr already does.
2209
- #
2210
- # auto_switch asks the attached client for its background colour over OSC 11
2211
- # and picks dark_name or light_name from the answer. That is the setting that
2212
- # lets one herdr server serve a dark terminal on one device and a light one on
2213
- # another - an e-ink panel among them - without either client changing the
2214
- # other. dark_name repeats `name` because a client that answers nothing at all
2215
- # falls back to dark.
2216
- themelines = []
2217
- if theme:
2218
- themelines = [f'name = "{theme}"']
2219
- if themelight:
2220
- themelines += [
2221
- "auto_switch = " + ("true" if themeauto == "true" else "false"),
2222
- f'dark_name = "{theme}"',
2223
- f'light_name = "{themelight}"',
2224
- ]
2225
-
2226
- toastlines = [f'delivery = "{toast}"'] if toast else []
2227
-
2228
- # TOML forbids a duplicate key, so a hand-written keymap that already sets
2229
- # something this script manages would make the merged file unparseable and
2230
- # herdr would reject the lot. Drop our own keys from the user's [keys] table,
2231
- # and any [[keys.command]] bound to a key we are about to claim, before
2232
- # inserting. Anything we do not manage is left exactly as it was.
2233
- def names(lines):
2234
- return {k.split("=", 1)[0].strip() for k in lines if "=" in k}
2235
-
2236
-
2237
- managed = names(keys)
2238
- # One set per table. An empty set strips nothing, so a table this run writes
2239
- # no value into leaves whatever the user wrote in it exactly as it was.
2240
- MANAGED_BY_TABLE = {"[keys]": managed, "[ui]": names(ui),
2241
- "[theme]": names(themelines), "[ui.toast]": names(toastlines)}
2242
- # A theme is one setting spelled across four keys, so it is claimed whole or
2243
- # not at all. Claiming only what gets written leaves a hand-written
2244
- # `auto_switch` and `light_name` beside the new `name`, and a light client then
2245
- # keeps the theme the user is replacing.
2246
- if themelines:
2247
- MANAGED_BY_TABLE["[theme]"] = {"name", "auto_switch", "dark_name", "light_name"}
2248
- claimed = {key for key, *_ in CUSTOM if key}
2249
-
2250
-
2251
- def strip_managed(table, names):
2252
- # Drop the settings this script manages, and the comment block that
2253
- # introduced each one. Removing the setting alone strands its comment: the
2254
- # user is left with a run of bare headings describing settings that are no
2255
- # longer there, which reads as a broken config.
2256
- #
2257
- # A value can run over several lines, because every key here holds an array
2258
- # and TOML lets one span as many lines as it likes. Dropping the first line
2259
- # alone strands the rest as bare list items, which is invalid TOML - and
2260
- # herdr answers that by rejecting the whole file, so one hand-written
2261
- # binding written that way would cost the user every binding.
2262
- out, pending, depth = [], [], 0
2263
- for ln in table:
2264
- if depth > 0:
2265
- depth += ln.count("[") - ln.count("]")
2266
- continue
2267
- stripped = ln.strip()
2268
- if stripped.startswith("#") or not stripped:
2269
- pending.append(ln)
2270
- continue
2271
- if "=" in ln and ln.split("=", 1)[0].strip() in names:
2272
- pending = []
2273
- value = ln.split("=", 1)[1]
2274
- depth = value.count("[") - value.count("]")
2275
- continue
2276
- out.extend(pending)
2277
- pending = []
2278
- out.append(ln)
2279
- out.extend(pending)
2280
- return out
2281
-
2282
-
2283
- # A table header on its own line, and nothing else. `line.startswith("[")` is
2284
- # not enough: a value spanning several lines puts its own list items at the
2285
- # start of a line, and ` [1, 2],` would then read as a new table.
2286
- HEADER = re.compile(r"^\s*\[\[?[^\[\]]*\]\]?\s*(?:#.*)?$")
2287
-
2288
-
2289
- def tables(lines):
2290
- current, depth = [], 0
2291
- for line in lines:
2292
- if depth <= 0 and HEADER.match(line) and current:
2293
- yield current
2294
- current = []
2295
- # Only outside a multi-line value, for the same reason.
2296
- if not HEADER.match(line):
2297
- depth += line.count("[") - line.count("]")
2298
- depth = max(depth, 0)
2299
- current.append(line)
2300
- if current:
2301
- yield current
2302
-
2303
-
2304
- kept = []
2305
- for table in tables(text.splitlines()):
2306
- header = table[0].strip()
2307
- # The managed block re-declares [worktrees] in full, and TOML forbids
2308
- # declaring a table twice.
2309
- if header == "[worktrees]":
2310
- continue
2311
- if header == "[[keys.command]]":
2312
- bound = re.search(r'(?m)^\s*key\s*=\s*"([^"]+)"', "\n".join(table))
2313
- if bound and bound.group(1) in claimed:
2314
- continue
2315
- if header in MANAGED_BY_TABLE:
2316
- table = strip_managed(table, MANAGED_BY_TABLE[header])
2317
- kept.extend(table)
2318
- text = "\n".join(kept)
2319
- if text and not text.endswith("\n"):
2320
- text += "\n"
2321
-
2322
- def merge(text, header, lines):
2323
- # Merge into a table the user already declared rather than declaring a
2324
- # second one, because TOML forbids declaring the same table twice. Returns
2325
- # the text and whether the merge happened.
2326
- # re.escape, because a sub-table header carries a dot: an unescaped
2327
- # "ui.toast" matches "[ui-toast]" too.
2328
- head = r"^\[" + re.escape(header) + r"\][ \t]*\n"
2329
- if not re.search(head, text, flags=re.M):
2330
- return text, False
2331
- region = begin + "\n" + "\n".join(lines) + "\n" + end
2332
- return re.sub(head, f"[{header}]\n" + region + "\n",
2333
- text, count=1, flags=re.M), True
2334
-
2335
-
2336
- block = []
2337
- for header, lines in (("keys", keys), ("ui", ui),
2338
- ("theme", themelines), ("ui.toast", toastlines)):
2339
- if not lines:
2340
- continue
2341
- text, merged = merge(text, header, lines)
2342
- if merged:
2343
- continue
2344
- if block:
2345
- block.append("")
2346
- block += [f"[{header}]"] + lines
2347
-
2348
- # [[keys.command]] and [worktrees] are top-level, so they can never sit inside
2349
- # a merged region and always land in a block of their own at the end.
2350
- block += custom_blocks() + ["", "[worktrees]", f'directory = "{wt}"']
2351
- text = text.rstrip("\n") + "\n\n" + "\n".join([begin] + block + [end]) + "\n"
2352
-
2353
- open(path, "w").write(text)
2354
- PY
2355
- if herdr config check 2>&1 | grep -q "^config: ok"; then
2356
- herdr server reload-config >/dev/null 2>&1
2357
- say "herdr keymap applied and reloaded"
2358
- else
2359
- say "herdr config check failed, review $HERDR_CFG"
2360
- herdr config check 2>&1 | sed 's/^/ /' | head -5
2361
- fi
2362
-
2363
- local integ; integ=$(cfg_get herdr.integration none)
2364
- if [ "$integ" != "none" ] && [ -n "$integ" ]; then
2365
- herdr integration install "$integ" >/dev/null 2>&1 && say "herdr $integ state hook installed"
2366
- fi
2367
- }
2368
-
2369
- build_tuicr_fork() {
2370
- # TEMPORARY path. Builds the two unmerged upstream pull requests the config
2371
- # comments name. Once both land, set build_from_fork: false and this whole
2372
- # function stops being reachable.
2373
- # `have cargo` is not enough: a rustup shim on PATH with no default
2374
- # toolchain installed exits non-zero on every invocation.
2375
- if ! cargo --version >/dev/null 2>&1; then
2376
- say "tuicr fork build needs a working cargo (see rustup.rs), skipping"
2377
- cargo --version 2>&1 | head -2 | sed 's/^/ /'
2378
- return 1
2379
- fi
2380
- have git || { say "tuicr fork build needs git, skipping"; return 1; }
2381
- local url branch dir
2382
- url=$(cfg_get tuicr.fork "https://github.com/ac8318740/tuicr")
2383
- branch=$(cfg_get tuicr.fork_branch "local/daily")
2384
- dir="${ACSTACK_TUICR_SRC:-$HOME/tuicr}"
2385
- if [ -d "$dir/.git" ]; then
2386
- git -C "$dir" fetch --quiet --all 2>/dev/null
2387
- git -C "$dir" checkout --quiet "$branch" 2>/dev/null || { say "tuicr: no branch $branch in $dir"; return 1; }
2388
- git -C "$dir" pull --quiet --ff-only 2>/dev/null
2389
- else
2390
- git clone --quiet -b "$branch" "$url" "$dir" || { say "tuicr: clone failed"; return 1; }
2391
- fi
2392
- say "building tuicr from $branch (a few minutes on a cold cache)"
2393
- local log; log=$(mktemp)
2394
- if ! ( cd "$dir" && cargo build --release ) >"$log" 2>&1; then
2395
- say "tuicr: build failed in $dir"
2396
- # Swallowing cargo's output here made a missing toolchain look identical
2397
- # to a compile error.
2398
- grep -E '^(error|warning: unused)' "$log" | head -5 | sed 's/^/ /'
2399
- tail -3 "$log" | sed 's/^/ /'
2400
- rm -f "$log"; return 1
2401
- fi
2402
- rm -f "$log"
2403
- mkdir -p "$BIN"
2404
- # mv, not cp: cp fails with "Text file busy" when tuicr is running.
2405
- cp "$dir/target/release/tuicr" "$BIN/tuicr.new" && mv -f "$BIN/tuicr.new" "$BIN/tuicr"
2406
- say "tuicr built from $branch and installed to $BIN/tuicr"
2407
- }
2408
-
2409
- apply_tuicr() {
2410
- local from_fork; from_fork=$(cfg_get tuicr.build_from_fork false)
2411
- if ! have tuicr; then
2412
- if [ "$from_fork" = "true" ]; then
2413
- build_tuicr_fork || return 0
2414
- else
2415
- install_binary tuicr agavra/tuicr x86_64-unknown-linux-gnu || return 0
2416
- fi
2417
- fi
2418
- have tuicr || { say "tuicr not installed, skipping config"; return 0; }
2419
-
2420
- mkdir -p "$HOME/.config/tuicr"
2421
- # show_file_line_stats and file_list_width only exist in the fork build.
2422
- # Writing them against a stock release makes tuicr warn on every start.
2423
- ACSTACK_ARGS="$from_fork|$(cfg_get tuicr.file_list_width 30)|$(cfg_get tuicr.show_file_line_stats true)|$(cfg_get tuicr.appearance "")|$BEGIN|$END" \
2424
- py "$HOME/.config/tuicr/config.toml" <<'PY'
2425
- import os, re, sys
2426
- path = sys.argv[1]
2427
- from_fork, width, stats, appearance, begin, end = os.environ["ACSTACK_ARGS"].split("|")
2428
- text = open(path).read() if os.path.isfile(path) else ""
2429
- text = re.sub(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", "", text, flags=re.S)
2430
-
2431
- block = [begin]
2432
- managed = set()
2433
- if from_fork == "true":
2434
- block += [
2435
- "# These two keys exist only in the fork build. See the tuicr section",
2436
- "# of ~/.config/acstack/terminal-workspace.yaml.",
2437
- f"show_file_line_stats = {stats}",
2438
- f"file_list_width = {width}",
2439
- "# A local build must not be replaced by a stock release.",
2440
- "no_update_check = true",
2441
- ]
2442
- managed |= {"show_file_line_stats", "file_list_width", "no_update_check"}
2443
- else:
2444
- block += ["# Stock release: the fork-only keys are omitted so tuicr does not",
2445
- "# warn about unknown config keys at startup."]
2446
- # `system` asks the terminal for its background colour over OSC 11. Under
2447
- # `herdr --remote` nothing answers, so tuicr falls back to a desktop setting
2448
- # that a headless machine reports as light, and paints dark text on a black
2449
- # terminal. Pinning the value sidesteps the whole detection path.
2450
- if appearance:
2451
- block += ["# Pinned rather than detected. See the tuicr section of",
2452
- "# ~/.config/acstack/terminal-workspace.yaml.",
2453
- f'appearance = "{appearance}"']
2454
- managed.add("appearance")
2455
- block.append(end)
2456
-
2457
- # TOML forbids a duplicate key, so a value the user set by hand before this
2458
- # script managed it would make the merged file unparseable and tuicr would
2459
- # reject the lot. Drop only the keys this run writes, and the comment block
2460
- # that introduced each one: the setting alone leaves a heading over nothing.
2461
- kept, pending, cut = [], [], None
2462
- for ln in text.splitlines():
2463
- stripped = ln.strip()
2464
- if stripped.startswith("#") or not stripped:
2465
- pending.append(ln)
2466
- continue
2467
- # Every key this script writes is top level, so the first table header ends
2468
- # the search. A key of the same name inside a table is a different key.
2469
- if cut is None and stripped.startswith("["):
2470
- # Before the comments introducing that header, not after them.
2471
- cut = len(kept)
2472
- if cut is None and "=" in ln and ln.split("=", 1)[0].strip().strip('"') in managed:
2473
- pending = []
2474
- continue
2475
- kept.extend(pending)
2476
- pending = []
2477
- kept.append(ln)
2478
- kept.extend(pending)
2479
-
2480
- # The block holds top-level keys, so it goes above the first table header
2481
- # rather than at the end of the file. Appended after a `[export]` or
2482
- # `[forge]` section, every key in it would belong to that section instead:
2483
- # tuicr would warn about an unknown key there and never see the setting, and
2484
- # a name the section already uses makes the file invalid outright.
2485
- if cut is None:
2486
- text = "\n".join(kept).rstrip("\n")
2487
- text = (text + "\n\n" if text else "") + "\n".join(block) + "\n"
2488
- else:
2489
- head = "\n".join(kept[:cut]).rstrip("\n")
2490
- text = ((head + "\n\n" if head else "") + "\n".join(block) + "\n\n"
2491
- + "\n".join(kept[cut:]).strip("\n") + "\n")
2492
- open(path, "w").write(text)
2493
- PY
2494
- say "tuicr config written"
2495
- }
2496
-
2497
- # The sidebar draws a workspace's position in herdr's stored list, but
2498
- # prefix+1..9 targets its row in the grouped sidebar. Creating or removing a
2499
- # worktree moves one and not the other, so the numbers you read stop being the
2500
- # numbers you press. This links a tiny herdr plugin that reruns the alignment
2501
- # on every event that can move a row, so it never needs remembering.
2502
- apply_herdr_numbers() {
2503
- have herdr || return 0
2504
- [ "$(cfg_get herdr.renumber_plugin true)" = "true" ] || return 0
2505
-
2506
- local dir="$HOME/.config/acstack/herdr-numbers"
2507
- mkdir -p "$dir"
2508
- # An absolute command: herdr runs argv without a shell and resolves relative
2509
- # commands from the plugin root, not from PATH.
2510
- cat > "$dir/herdr-plugin.toml" <<H
2511
- id = "acstack.herdr-numbers"
2512
- name = "ACStack workspace numbers"
2513
- version = "1.0.0"
2514
- min_herdr_version = "0.8.0"
2515
- description = "Keep the sidebar numbers matching prefix+1..9"
2516
- platforms = ["linux", "macos", "windows"]
2517
-
2518
- # Every event that can move a sidebar row. workspace.moved and
2519
- # workspace.reordered are deliberately absent: the realignment emits both, so
2520
- # hooking them would loop forever. The helper is idempotent, so the overlap
2521
- # between the workspace and worktree events costs nothing.
2522
- [[events]]
2523
- on = "workspace.created"
2524
- command = ["$BIN/acstack-herdr-renumber"]
2525
-
2526
- [[events]]
2527
- on = "workspace.closed"
2528
- command = ["$BIN/acstack-herdr-renumber"]
2529
-
2530
- [[events]]
2531
- on = "worktree.created"
2532
- command = ["$BIN/acstack-herdr-renumber"]
2533
-
2534
- [[events]]
2535
- on = "worktree.opened"
2536
- command = ["$BIN/acstack-herdr-renumber"]
2537
-
2538
- [[events]]
2539
- on = "worktree.removed"
2540
- command = ["$BIN/acstack-herdr-renumber"]
2541
- H
2542
-
2543
- if herdr plugin list 2>/dev/null | grep -q "acstack.herdr-numbers"; then
2544
- say "herdr numbers plugin already linked"
2545
- elif herdr plugin link "$dir" >/dev/null 2>&1; then
2546
- say "herdr numbers plugin linked, sidebar numbers stay aligned"
2547
- else
2548
- say "herdr plugin link failed; run acstack-herdr-renumber by hand"
2549
- fi
2550
- }
2551
-
2552
- apply_yazi() {
2553
- [ "$(cfg_get yazi.enabled true)" = "true" ] || return 0
2554
- if ! have yazi; then
2555
- install_binary yazi sxyazi/yazi x86_64-unknown-linux-gnu || return 0
2556
- install_binary ya sxyazi/yazi x86_64-unknown-linux-gnu || true
2557
- fi
2558
- have yazi || { say "yazi not installed, skipping config"; return 0; }
2559
-
2560
- # piper turns any shell command into a previewer, which is how acstack-md
2561
- # gets to draw markdown in the preview pane.
2562
- if have ya && ! ya pkg list 2>/dev/null | grep -q "plugins:piper"; then
2563
- ya pkg add yazi-rs/plugins:piper >/dev/null 2>&1 \
2564
- && say "yazi piper plugin installed" \
2565
- || say "piper install failed; markdown will preview as plain text"
2566
- fi
2567
-
2568
- mkdir -p "$HOME/.config/yazi"
2569
- ACSTACK_ARGS="$(cfg_get yazi.show_hidden true)|$BEGIN|$END" \
2570
- py "$HOME/.config/yazi/yazi.toml" <<'PY'
2571
- import os, re, sys
2572
- path = sys.argv[1]
2573
- hidden, begin, end = os.environ["ACSTACK_ARGS"].split("|")
2574
- text = open(path).read() if os.path.isfile(path) else ""
2575
- # Drop any previous managed region, both to stay idempotent and so what is
2576
- # left to inspect below is exactly the config the user wrote.
2577
- text = re.sub(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", "", text, flags=re.S)
2578
-
2579
- # Every namespace the block writes into. TOML forbids declaring any of them
2580
- # twice, and yazi answers one duplicate by throwing the entire config away and
2581
- # falling back to presets. So a namespace the user already occupies is theirs:
2582
- # the block gives that piece up rather than cost them every other setting they
2583
- # have. Deciding which are taken is a question about the parsed document, not
2584
- # about the text: [ mgr ], [ "mgr" ], [ 'mgr' ] and mgr.show_hidden = false are
2585
- # all the same table, and only a parser knows that.
2586
- NAMESPACES = ("mgr", "opener.markdown", "plugin.prepend_previewers", "open.prepend_rules")
2587
-
2588
- try:
2589
- import tomllib
2590
- except ImportError: # tomllib is 3.11 and newer
2591
- tomllib = None
2592
-
2593
-
2594
- def claimed(cfg):
2595
- """Which of NAMESPACES the user's own config already occupies."""
2596
- if tomllib is None:
2597
- # No parser to reason with, so read the text conservatively: name the
2598
- # top-level table wherever TOML could open one, as a header or as a
2599
- # key of its own. That concedes namespaces which would have been safe
2600
- # to write, and each of those costs one setting; guessing the other
2601
- # way costs the file. One thing text cannot see is that the config is
2602
- # broken already, so unlike the parsed path below this branch does not
2603
- # concede everything for a file that never parsed.
2604
- found = set()
2605
- for ns in NAMESPACES:
2606
- root = re.escape(ns.split(".")[0])
2607
- # A key may be bare, a basic string or a literal string, and all
2608
- # three name the same table: [mgr], ["mgr"] and ['mgr'] are one.
2609
- name = r"""(?:%s|"%s"|'%s')""" % (root, root, root)
2610
- header = r'\[\[?\s*%s\s*[.\]]' % name
2611
- key = r'%s\s*[.=]' % name
2612
- if re.search(r"(?m)^\s*(?:%s|%s)" % (header, key), cfg):
2613
- found.add(ns)
2614
- return found
2615
- try:
2616
- tomllib.loads(cfg)
2617
- except tomllib.TOMLDecodeError:
2618
- # Their config does not parse as it stands. We cannot tell what it
2619
- # claims, and repairing it is not ours to do, so concede everything:
2620
- # the file ends up exactly as broken as it arrived, never more.
2621
- return set(NAMESPACES)
2622
- found = set()
2623
- for ns in NAMESPACES:
2624
- # Whether a namespace is free is not "is the key there". TOML also
2625
- # refuses to reopen an inline table and to overwrite a scalar, so
2626
- # opener = { text = [...] } and opener = "nope" both have no
2627
- # opener.markdown key and are both fatal to write under. tomllib
2628
- # hands an inline table back as an ordinary dict, so no walk over the
2629
- # parsed document can tell those apart from a table that is still
2630
- # open. Put the question to the parser instead, in the exact form the
2631
- # block would write it: append that header to their config and see
2632
- # whether the whole thing still parses. Whatever it refuses is theirs.
2633
- probe = "[mgr]" if ns == "mgr" else "[[%s]]" % ns
2634
- try:
2635
- tomllib.loads(cfg + "\n" + probe + "\n")
2636
- except tomllib.TOMLDecodeError:
2637
- found.add(ns)
2638
- return found
2639
-
2640
-
2641
- taken = claimed(text)
2642
- parts = []
2643
-
2644
- if "plugin.prepend_previewers" not in taken:
2645
- parts.append("""
2646
- [[plugin.prepend_previewers]]
2647
- url = "*.md"
2648
- run = 'piper -- COLUMNS=$w acstack-md --preview "$1"'
2649
-
2650
- [[plugin.prepend_previewers]]
2651
- url = "*.markdown"
2652
- run = 'piper -- COLUMNS=$w acstack-md --preview "$1"'
2653
- """)
2654
-
2655
- if "opener.markdown" not in taken:
2656
- # An array of tables rather than a markdown key under [opener]: a second
2657
- # [opener] table would be a duplicate, while a subtable of one the user
2658
- # has already declared is legal.
2659
- parts.append("""
2660
- # The preview pane is narrow, so a wide diagram shows its placeholder there.
2661
- # Enter opens the same renderer full width, where more of them fit.
2662
- #
2663
- # %s is what yazi substitutes for the files being opened, already quoted. Not
2664
- # "$@": an opener template is run as `sh -c '<run>'` with nothing after it, so
2665
- # $0 is "sh" and $@ is empty - measured on yazi 26.8.15, where "$@" left
2666
- # acstack-md with no file at all and Enter did nothing but print its usage.
2667
- [[opener.markdown]]
2668
- run = 'acstack-md %s'
2669
- block = true
2670
- desc = "Read (acstack-md)"
2671
-
2672
- # Rendered markdown has no line number to quote back in a review, so the same
2673
- # file is also readable as its own source. Second, not first: it answers a
2674
- # narrower question than reading does, and still a readier one than editing.
2675
- # Enter takes the first entry, so O is the key that offers this one.
2676
- [[opener.markdown]]
2677
- run = 'acstack-md --numbered %s'
2678
- block = true
2679
- desc = "Read with line numbers"
2680
-
2681
- [[opener.markdown]]
2682
- run = '${EDITOR:-vi} %s'
2683
- block = true
2684
- desc = "Edit"
2685
- """)
2686
-
2687
- if "open.prepend_rules" not in taken:
2688
- # Worth writing even when the opener above was skipped: the rules point
2689
- # markdown files at whichever opener.markdown ends up in the file.
2690
- parts.append("""
2691
- [[open.prepend_rules]]
2692
- url = "*.md"
2693
- use = "markdown"
2694
-
2695
- [[open.prepend_rules]]
2696
- url = "*.markdown"
2697
- use = "markdown"
2698
- """)
2699
-
2700
- if "mgr" not in taken:
2701
- parts.append(f"[mgr]\nshow_hidden = {hidden}")
65
+ . "$SCRIPT_DIR/lib/core.sh"
66
+ . "$SCRIPT_DIR/lib/helper-install.sh"
67
+ . "$SCRIPT_DIR/lib/apply-herdr.sh"
68
+ . "$SCRIPT_DIR/lib/apply-tuicr.sh"
69
+ . "$SCRIPT_DIR/lib/apply-herdr-numbers.sh"
70
+ . "$SCRIPT_DIR/lib/apply-yazi.sh"
71
+ . "$SCRIPT_DIR/lib/apply-tools.sh"
72
+ . "$SCRIPT_DIR/lib/apply-ghdash.sh"
73
+ . "$SCRIPT_DIR/lib/pi-state.sh"
74
+ . "$SCRIPT_DIR/lib/pi-packages.sh"
75
+ . "$SCRIPT_DIR/lib/versions.sh"
76
+ . "$SCRIPT_DIR/lib/commands.sh"
2702
77
 
2703
- # The markers go down even when every piece was conceded, so the shell below
2704
- # can still read the region back and see what is missing from it.
2705
- block = begin + "\n" + "\n\n".join(p.strip("\n") for p in parts) + "\n" + end
2706
- text = text.rstrip("\n")
2707
- open(path, "w").write((text + "\n\n" if text else "") + block + "\n")
2708
- PY
2709
- # A setting the block gave up to the user's own config is simply absent from
2710
- # what was written, so read the region back rather than let it go unsaid.
2711
- local written; written="$(sed -n "/$BEGIN/,/$END/p" "$HOME/.config/yazi/yazi.toml")"
2712
- case "$written" in *"[[plugin.prepend_previewers]]"*) ;; *)
2713
- say "yazi: your yazi.toml already sets plugin.prepend_previewers, so the"
2714
- say " markdown previewer was left alone. Add a *.md entry there to"
2715
- say " preview markdown with acstack-md." ;;
2716
- esac
2717
- case "$written" in *"[[opener.markdown]]"*) ;; *)
2718
- say "yazi: your yazi.toml already opens markdown its own way, so it was left"
2719
- say " alone. Add acstack-md to that opener to read markdown with it." ;;
2720
- esac
2721
- case "$written" in *"[[open.prepend_rules]]"*) ;; *)
2722
- say "yazi: your yazi.toml already sets open.prepend_rules, so the markdown"
2723
- say " rules were left alone. Add *.md and *.markdown entries there"
2724
- say " pointing at the markdown opener." ;;
2725
- esac
2726
- case "$written" in *show_hidden*) ;; *)
2727
- say "yazi: your yazi.toml already claims mgr itself, so show_hidden was left"
2728
- say " alone. Set it there yourself to show hidden files." ;;
2729
- esac
2730
- # The keymap is its own file, and prepend_keymap has two spellings: an array
2731
- # of tables, which is additive and safe to extend, and an inline array under
2732
- # [mgr], which is one key that TOML forbids declaring twice. Only the second
2733
- # collides with the entry below, and telling them apart takes the text - both
2734
- # spellings parse to the same list.
2735
- ACSTACK_ARGS="$(cfg_get yazi.browser_key "b")|$(cfg_get yazi.line_numbers_key "#")|$(cfg_get yazi.download_key "D")|$(cfg_get yazi.download_target "")|$(cfg_get yazi.edit_key "e")|$BEGIN|$END" \
2736
- py "$HOME/.config/yazi/keymap.toml" <<'PY'
2737
- import os, re, sys
2738
-
2739
- path = sys.argv[1]
2740
- key, numkey, dlkey, dltarget, editkey, begin, end = os.environ["ACSTACK_ARGS"].split("|")
2741
- text = open(path).read() if os.path.isfile(path) else ""
2742
- # Drop any previous managed region, both to stay idempotent and so what is
2743
- # left to inspect below is exactly the keymap the user wrote.
2744
- text = re.sub(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", "", text, flags=re.S)
2745
-
2746
- try:
2747
- import tomllib
2748
- except ImportError: # tomllib is 3.11 and newer
2749
- tomllib = None
2750
-
2751
-
2752
- def claimed(t):
2753
- """True when the user's keymap declares prepend_keymap as an inline array
2754
- under [mgr] - the one spelling our own entry cannot sit beside."""
2755
- if not re.search(r"^\s*prepend_keymap\s*=", t, flags=re.M):
2756
- return False
2757
- if tomllib is None:
2758
- # No parser to say which table that assignment sits in, so assume the
2759
- # worst. A conceded binding costs one key; a collision costs the whole
2760
- # keymap, because yazi answers invalid TOML by falling back to presets.
2761
- return True
2762
- try:
2763
- return "prepend_keymap" in (tomllib.loads(t).get("mgr") or {})
2764
- except Exception:
2765
- return True
2766
-
2767
-
2768
- CLAIMED = {k for k in (key, numkey, editkey) if k}
2769
- if dltarget and dlkey:
2770
- CLAIMED.add(dlkey)
2771
-
2772
-
2773
- def drop_claimed(t):
2774
- """Remove any prepend_keymap block the user wrote on a key this script is
2775
- about to bind.
2776
-
2777
- yazi accepts both, lists both in its help, and runs one of them. So a
2778
- binding written by hand before this script managed the key leaves two
2779
- entries saying different things and no way to tell which one fires. Both
2780
- table spellings, because `manager` is what yazi called `mgr` before the
2781
- rename and an older keymap still says it.
2782
- """
2783
- out, block = [], None
2784
-
2785
- def bound_key(lines):
2786
- """The single key this block binds, or None.
2787
-
2788
- yazi spells `on` four ways: a double-quoted scalar, a single-quoted
2789
- one, a one-element array, and any of those with a trailing comment.
2790
- Reading only the first spelling leaves the other three in place, which
2791
- is the state this function exists to prevent. A longer array is a
2792
- multi-key sequence and never collides with a single key.
2793
- """
2794
- body = "\n".join(lines[1:])
2795
- if tomllib is not None:
2796
- try:
2797
- on = tomllib.loads(body).get("on")
2798
- except Exception:
2799
- on = ... # unparseable, so fall through to the regex
2800
- if on is not ...:
2801
- if isinstance(on, str):
2802
- return on
2803
- if isinstance(on, list) and len(on) == 1 and isinstance(on[0], str):
2804
- return on[0]
2805
- return None
2806
- m = re.search(r"""(?m)^\s*on\s*=\s*\[?\s*(?:"([^"]*)"|'([^']*)')""", body)
2807
- return (m.group(1) if m.group(1) is not None else m.group(2)) if m else None
2808
-
2809
- def close():
2810
- nonlocal block
2811
- if block is None:
2812
- return
2813
- # Trailing blank lines belong to whatever comes next, not to the
2814
- # binding, so they stay even when the binding goes.
2815
- tail = []
2816
- while block and not block[-1].strip():
2817
- tail.insert(0, block.pop())
2818
- if bound_key(block) not in CLAIMED:
2819
- out.extend(block)
2820
- out.extend(tail)
2821
- block = None
2822
-
2823
- for ln in t.splitlines():
2824
- if re.match(r"^\s*\[\[\s*(?:mgr|manager)\.prepend_keymap\s*\]\]\s*$", ln):
2825
- close()
2826
- block = [ln]
2827
- continue
2828
- if block is not None:
2829
- # Any other table header ends this binding and belongs to what
2830
- # follows, so it is never dropped with the block.
2831
- if ln.lstrip().startswith("["):
2832
- close()
2833
- out.append(ln)
2834
- continue
2835
- block.append(ln)
2836
- continue
2837
- out.append(ln)
2838
- close()
2839
- return "\n".join(out)
2840
-
2841
-
2842
- parts = []
2843
- if not claimed(text):
2844
- # A binding conceded below is one this script never writes, so the user's
2845
- # own must survive. Only strip when the bindings are actually going in.
2846
- text = drop_claimed(text)
2847
- # %h is the hovered file. $0 and $@ are not: yazi runs the template through
2848
- # a shell, where $0 names the shell itself and $@ is empty - both measured,
2849
- # and both would open the browser on nothing at all.
2850
- #
2851
- # %h stays bare, here and in every binding below. yazi shell-quotes it
2852
- # itself, so quotes around it end up inside the path and nothing is found.
2853
- #
2854
- # --block because the delivery has something to say. On the bridge it is a
2855
- # line of confirmation; on every other route it is a link, and a served
2856
- # page that has to stay up. Detached, all of that goes nowhere.
2857
- parts.append(
2858
- "[[mgr.prepend_keymap]]\n"
2859
- f'on = "{key}"\n'
2860
- """run = 'shell --block -- acstack-md --browser %h'\n"""
2861
- 'desc = "Open in the browser you are sitting at"'
2862
- )
2863
- # Flipping the flag changes nothing a reader can see: the pane already
2864
- # holds the view it drew before the key was pressed. So the binding is two
2865
- # actions, and peek redraws it.
2866
- #
2867
- # --block, though the toggle has nothing to say. Detached, yazi spawns it
2868
- # and runs peek immediately, and the pane is redrawn while the flag is
2869
- # still whatever it was - a race the reader settles by pressing the key
2870
- # twice. Blocking costs a frame and wins it outright.
2871
- parts.append(
2872
- "[[mgr.prepend_keymap]]\n"
2873
- f'on = "{numkey}"\n'
2874
- "run = [ 'shell --block -- acstack-md --toggle-line-numbers', 'peek --force' ]\n"
2875
- 'desc = "Preview markdown as source with line numbers"'
2876
- )
2877
- # `e` edits, everywhere. tuicr's diff and file tree both put the editor on
2878
- # `e`, so yazi matching it means one key means edit across the workspace.
2879
- # `o` is left alone: it opens by rule, which for markdown is the reader.
2880
- #
2881
- # ${EDITOR:-vi} rather than a fixed editor, the same expansion the opener
2882
- # in yazi.toml uses, so both paths land in whatever the user actually runs.
2883
- parts.append(
2884
- "[[mgr.prepend_keymap]]\n"
2885
- f'on = "{editkey}"\n'
2886
- """run = 'shell --block -- ${EDITOR:-vi} %h'\n"""
2887
- 'desc = "Edit in $EDITOR"'
2888
- )
2889
- # Taildrop is Tailscale's file send. It is the one route off a headless
2890
- # machine that needs nothing installed on the machine the user sits at,
2891
- # and no inbound port there either.
2892
- #
2893
- # %h, the hovered file, for the same reason the browser key uses it. %* is
2894
- # not a keybinding placeholder at all - yazi passes it through untouched,
2895
- # and tailscale then reports `open %*: no such file or directory`.
2896
- # Measured on yazi 26.8.15.
2897
- #
2898
- # --block because a send reports progress, and a refused one reports why.
2899
- if dltarget:
2900
- parts.append(
2901
- "[[mgr.prepend_keymap]]\n"
2902
- f'on = "{dlkey}"\n'
2903
- f"""run = 'shell --block -- tailscale file cp %h {dltarget}:'\n"""
2904
- f'desc = "Send this file to {dltarget} over Taildrop"'
2905
- )
2906
-
2907
- # The markers go down even when the binding was conceded, so the shell below
2908
- # can read the region back and see what is missing from it.
2909
- block = begin + "\n" + "\n\n".join(parts) + ("\n" if parts else "") + end
2910
- text = text.rstrip("\n")
2911
- open(path, "w").write((text + "\n\n" if text else "") + block + "\n")
2912
- PY
2913
- local kmwritten; kmwritten="$(sed -n "/$BEGIN/,/$END/p" "$HOME/.config/yazi/keymap.toml")"
2914
- case "$kmwritten" in *prepend_keymap*) ;; *)
2915
- say "yazi: your keymap.toml already sets mgr.prepend_keymap as an inline"
2916
- say " array, which these bindings cannot sit beside. Add them there"
2917
- say " yourself: shell --block -- acstack-md --browser %h"
2918
- say " and: [ 'shell --block -- acstack-md --toggle-line-numbers', 'peek --force' ]"
2919
- say " and: shell --block -- \${EDITOR:-vi} %h" ;;
2920
- esac
2921
- # The download key is written from the config alone, so it can be wrong in
2922
- # three ways the config cannot see. Name whichever one holds, rather than
2923
- # leaving the user to read `status code: 1` out of a yazi popup.
2924
- local dltarget; dltarget="$(cfg_get yazi.download_target "")"
2925
- if [ -n "$dltarget" ]; then
2926
- if ! have tailscale; then
2927
- say "yazi: download_target names $dltarget, but this machine has no"
2928
- say " tailscale. Install it, or clear the key to drop the binding."
2929
- elif ! tailscale file cp --targets >/dev/null 2>&1; then
2930
- say "yazi: tailscale refuses file access to this user account. Run once:"
2931
- say " sudo tailscale set --operator=\$USER"
2932
- elif ! tailscale file cp --targets 2>/dev/null \
2933
- | awk -F'\t' -v t="$dltarget" '$2 == t { f = 1 } END { exit !f }'; then
2934
- say "yazi: $dltarget is not a Taildrop target of this machine. Taildrop"
2935
- say " only sends between devices one Tailscale account owns on one"
2936
- say " tailnet. Check both ends with: tailscale file cp --targets"
2937
- fi
2938
- fi
2939
- say "yazi config written"
2940
- }
2941
-
2942
- apply_markdown() {
2943
- [ "$(cfg_get markdown.enabled true)" = "true" ] || return 0
2944
- install_binary mermaid-ascii AlexanderGrooff/mermaid-ascii Linux_x86_64
2945
- install_binary glow charmbracelet/glow Linux_x86_64
2946
- # Vendored so the preview page pulls nothing from a CDN: it works offline,
2947
- # and on a managed laptop there is no third-party fetch to explain.
2948
- local share="$HOME/.local/share/acstack"
2949
- mkdir -p "$share"
2950
- if [ ! -s "$share/mermaid.min.js" ]; then
2951
- curl -fsSL -o "$share/mermaid.min.js" \
2952
- "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js" \
2953
- && say "mermaid.js vendored to $share" \
2954
- || say "mermaid.js download failed, the preview will fall back to a CDN"
2955
- fi
2956
- python3 -c 'import markdown' 2>/dev/null \
2957
- || say "acstack-md --serve needs python markdown: pip install --user markdown"
2958
- have chafa || say "optional: apt install chafa, to draw images as text"
2959
- }
2960
-
2961
- # The plugin file's name is ours by convention alone, and the directory around
2962
- # it is the user's. A file of that name this script did not write is theirs,
2963
- # so apply must not overwrite it and disable must not delete it. It says so
2964
- # where it refuses, so both paths report it the same way.
2965
- nvim_ours() {
2966
- [ ! -e "$NVIM_PLUGIN" ] && return 0
2967
- grep -q "$NVIM_MARK" "$NVIM_PLUGIN" 2>/dev/null && return 0
2968
- say "neovim: $NVIM_PLUGIN was written by hand, left alone"
2969
- return 1
2970
- }
2971
-
2972
- # The dot is appended to LazyVim's lualine section, and LazyVim loads every
2973
- # file under lua/plugins. So a lualine override the user wrote in a file of
2974
- # their own does exactly what this component does, and installing on top of it
2975
- # draws two dots side by side. Print the file that already does it, if any.
2976
- nvim_dot_elsewhere() {
2977
- local dir="$HOME/.config/nvim/lua/plugins" f
2978
- [ -d "$dir" ] || return 1
2979
- for f in "$dir"/*.lua; do
2980
- [ -e "$f" ] || continue
2981
- [ "$f" = "$NVIM_PLUGIN" ] && continue
2982
- # Both, not either: a lualine override that says nothing about a modified
2983
- # buffer is not this dot, and neither is a modified check outside lualine.
2984
- grep -q 'lualine' "$f" 2>/dev/null || continue
2985
- grep -q 'vim\.bo\.modified' "$f" 2>/dev/null || continue
2986
- printf '%s\n' "$f"
2987
- return 0
2988
- done
2989
- return 1
2990
- }
2991
-
2992
- # Print the first lua file of the user's own that sets a pattern, ignoring
2993
- # comment lines, since a setting tried and commented out decides nothing.
2994
- nvim_user_sets() {
2995
- local f
2996
- while IFS= read -r f; do
2997
- grep -v '^[[:space:]]*--' "$f" 2>/dev/null | grep -q "$1" || continue
2998
- printf '%s\n' "$f"
2999
- return 0
3000
- done < <(find "$HOME/.config/nvim" -name '*.lua' ! -path "$NVIM_PLUGIN" 2>/dev/null | sort)
3001
- return 1
3002
- }
3003
-
3004
- # The clipboard part of the plugin file. The comment inside it says why.
3005
- NVIM_CLIPBOARD_LUA='
3006
- -- Over SSH, neovim finds no clipboard and :%y+ fails. Copy goes out over
3007
- -- OSC 52, which asks the terminal you sit at to put the text on its own
3008
- -- clipboard. Paste replays what this neovim copied last, because Windows
3009
- -- Terminal refuses OSC 52 reads. Use the terminal paste key for anything else.
3010
- local function set(v)
3011
- return v ~= nil and v ~= ""
3012
- end
3013
- if not (set(vim.env.DISPLAY) or set(vim.env.WAYLAND_DISPLAY) or set(vim.env.TMUX)
3014
- or vim.fn.has("mac") == 1 or vim.fn.has("wsl") == 1) then
3015
- local osc52 = require("vim.ui.clipboard.osc52")
3016
- local last = {}
3017
- local function copy(reg)
3018
- local send = osc52.copy(reg)
3019
- return function(lines, regtype)
3020
- last[reg] = { lines, regtype }
3021
- send(lines)
3022
- end
3023
- end
3024
- local function paste(reg)
3025
- return function()
3026
- return last[reg] or { { "" }, "v" }
3027
- end
3028
- end
3029
- vim.g.clipboard = {
3030
- name = "OSC 52 (acstack)",
3031
- copy = { ["+"] = copy("+"), ["*"] = copy("*") },
3032
- paste = { ["+"] = paste("+"), ["*"] = paste("*") },
3033
- }
3034
- '
3035
- # LazyVim empties clipboard over SSH, then restores that value on VeryLazy.
3036
- # Scheduled, this runs after the restore, so a plain y copies too. It is left
3037
- # out when the user sets clipboard themselves.
3038
- NVIM_UNNAMEDPLUS_LUA=' vim.api.nvim_create_autocmd("User", {
3039
- pattern = "VeryLazy",
3040
- once = true,
3041
- callback = function()
3042
- vim.schedule(function()
3043
- if vim.o.clipboard == "" then
3044
- vim.opt.clipboard = "unnamedplus"
3045
- end
3046
- end)
3047
- end,
3048
- })
3049
- '
3050
-
3051
- apply_neovim() {
3052
- # The one component that is off unless asked for. Every other one writes
3053
- # files this setup owns end to end, or a marked region inside a config that
3054
- # invites one. A neovim config is neither: the user built it, so this waits.
3055
- [ "$(cfg_get neovim.enabled false)" = "true" ] || return 0
3056
- # The dot appends to LazyVim's own lualine section, and the clipboard undoes
3057
- # a LazyVim default, so both mean nothing on a config that is not LazyVim.
3058
- # Two markers, because a LazyVim config never opened has no plugins yet.
3059
- if ! grep -q LazyVim "$HOME/.config/nvim/lua/config/lazy.lua" 2>/dev/null \
3060
- && [ ! -d "$HOME/.local/share/nvim/lazy/LazyVim" ]; then
3061
- say "neovim: no LazyVim config on this machine, plugin file not written"
3062
- return 0
3063
- fi
3064
- nvim_ours || return 0
3065
-
3066
- local dot_spec="" clip_lua="" mine
3067
- if mine="$(nvim_dot_elsewhere)"; then
3068
- say "neovim: $mine already marks a modified buffer in lualine."
3069
- # nvim_ours has already run, so a file at this path is one we wrote. A user
3070
- # who installed first and wrote their own override later would otherwise
3071
- # keep both, which is the pair of dots this guard exists to prevent.
3072
- if grep -q 'vim\.bo\.modified' "$NVIM_PLUGIN" 2>/dev/null; then
3073
- say " The dot this component wrote was removed, so only one is drawn."
3074
- else
3075
- say " The dot was not written. Two would be drawn side by side."
3076
- fi
3077
- say " Delete $mine to hand the dot back to this component."
3078
- else
3079
- local dot; dot="$(cfg_get neovim.unsaved_dot_color '#f38ba8')"
3080
- dot_spec="$(cat <<H
3081
-
3082
- -- LazyVim marks an unsaved buffer by recolouring the filename and nothing
3083
- -- else, because its pretty_path component sets modified_sign = "". A glance
3084
- -- at the statusline then cannot tell a saved buffer from an unsaved one.
3085
- -- This appends a dot to the same section, which can.
3086
- return {
3087
- {
3088
- "nvim-lualine/lualine.nvim",
3089
- opts = function(_, opts)
3090
- table.insert(opts.sections.lualine_c, {
3091
- function()
3092
- return vim.bo.modified and "●" or ""
3093
- end,
3094
- color = { fg = "$dot" },
3095
- })
3096
- end,
3097
- },
3098
- }
3099
- H
3100
- )"
3101
- fi
3102
-
3103
- if [ "$(cfg_get neovim.osc52_clipboard true)" = "true" ]; then
3104
- # Whichever lua sets vim.g.clipboard last wins, and the user's own files
3105
- # load after ours, so ours would only add a forced clipboard option.
3106
- if mine="$(nvim_user_sets 'g\.clipboard')"; then
3107
- say "neovim: $mine already sets vim.g.clipboard."
3108
- if grep -q 'vim\.g\.clipboard' "$NVIM_PLUGIN" 2>/dev/null; then
3109
- say " The OSC 52 copy this component wrote was removed."
3110
- else
3111
- say " The OSC 52 copy was not written."
3112
- fi
3113
- else
3114
- clip_lua="$NVIM_CLIPBOARD_LUA"
3115
- if mine="$(nvim_user_sets '[.]o[pt]*[.]clipboard')"; then
3116
- say "neovim: $mine sets clipboard, so y keeps the value set there"
3117
- else
3118
- clip_lua="$clip_lua$NVIM_UNNAMEDPLUS_LUA"
3119
- fi
3120
- clip_lua="${clip_lua}end
3121
- "
3122
- fi
3123
- fi
3124
-
3125
- if [ -z "$dot_spec$clip_lua" ]; then
3126
- rm -f "$NVIM_PLUGIN"
3127
- return 0
3128
- fi
3129
- mkdir -p "$(dirname "$NVIM_PLUGIN")"
3130
- {
3131
- printf -- '-- %s. Remove it with: setup.sh disable neovim\n' "$NVIM_MARK"
3132
- printf '%s' "$clip_lua"
3133
- # Every file under lua/plugins must return a spec, even an empty one.
3134
- if [ -n "$dot_spec" ]; then printf '%s\n' "$dot_spec"; else printf '\nreturn {}\n'; fi
3135
- } > "$NVIM_PLUGIN"
3136
- [ -n "$dot_spec" ] && say "neovim: unsaved-changes dot written to $NVIM_PLUGIN"
3137
- [ -n "$clip_lua" ] && say "neovim: OSC 52 copy written to $NVIM_PLUGIN"
3138
- return 0
3139
- }
3140
-
3141
- apply_delta() {
3142
- have delta || { say "delta not installed, skipping git pager"; return 0; }
3143
- [ "$(cfg_get delta.set_git_pager true)" = "true" ] || { say "delta: git pager left alone"; return 0; }
3144
- git config --global core.pager delta
3145
- git config --global interactive.diffFilter "delta --color-only"
3146
- git config --global delta.navigate true
3147
- git config --global delta.line-numbers true
3148
- git config --global merge.conflictstyle zdiff3
3149
- say "delta set as git pager"
3150
- }
3151
-
3152
- apply_remote() {
3153
- # A VM reached over SSH has no display and no clipboard of its own, so
3154
- # anything that shells out to xclip fails. Put an xclip on PATH that speaks
3155
- # OSC 52 to the terminal at the far end instead.
3156
- [ "$(cfg_get remote.clipboard_shim true)" = "true" ] || {
3157
- say "clipboard: xclip stand-in not installed"; return 0; }
3158
- local real
3159
- # Only when the machine has neither a real xclip nor a display for one to
3160
- # talk to. Shadowing a working clipboard would be a downgrade. A stand-in
3161
- # this script wrote before is not a real one, wherever it turns up.
3162
- real="$(command -v xclip 2>/dev/null)"
3163
- if [ -n "$real" ] && ! grep -q "Installed by acstack" "$real" 2>/dev/null; then
3164
- say "clipboard: real xclip at $real, stand-in not installed"
3165
- return 0
3166
- fi
3167
- if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then
3168
- say "clipboard: this machine has a display, stand-in not installed"
3169
- return 0
3170
- fi
3171
- cat > "$BIN/xclip" <<'H'
3172
- #!/usr/bin/env bash
3173
- # xclip, for a machine whose clipboard is at the other end of an SSH session.
3174
- # acstack installs this only when no real xclip is present.
3175
- #
3176
- # It exists for programs that reach for xclip directly instead of offering a
3177
- # setting. gh-dash copies pull request URLs through a Go library that looks
3178
- # only for xclip, xsel, wl-copy and termux-clipboard-set, and gives up when
3179
- # none is on PATH. This puts one there, and acstack-clip sends the text to
3180
- # the terminal's own clipboard with OSC 52.
3181
- #
3182
- # Hands over to a real xclip if one turns up on PATH with a display to talk
3183
- # to. Remove it with `setup.sh uninstall`. Installed by acstack.
3184
- set -uo pipefail
3185
-
3186
- PATH="$(cd "$(dirname "$0")" 2>/dev/null && pwd):$PATH"
3187
-
3188
- # A real xclip, anywhere on PATH except beside this script.
3189
- if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then
3190
- self="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
3191
- while IFS= read -r candidate; do
3192
- [ "$(cd "$(dirname "$candidate")" 2>/dev/null && pwd)" = "$self" ] && continue
3193
- exec "$candidate" "$@"
3194
- done < <(type -ap xclip 2>/dev/null)
78
+ if [ "${ACSTACK_TW_NO_MAIN:-0}" != "1" ] && [ "${BASH_SOURCE[0]:-$0}" = "$0" ]; then
79
+ acstack_tw_main "$@"
3195
80
  fi
3196
-
3197
- for arg in "$@"; do
3198
- case "$arg" in
3199
- -o|-out|-output) exec acstack-clip --out ;;
3200
- esac
3201
- done
3202
- exec acstack-clip
3203
- H
3204
- chmod +x "$BIN/xclip"
3205
- say "clipboard: xclip stand-in written, copying over OSC 52"
3206
- }
3207
-
3208
- apply_harlequin() {
3209
- # uv is astral's python installer. harlequin is a python program, so there is
3210
- # no release tarball to unpack: uv builds it an environment of its own and
3211
- # links one command into ~/.local/bin. Nothing else here needs uv, so a
3212
- # machine without it loses this component and keeps the rest.
3213
- have uv || {
3214
- say "uv not installed, skipping harlequin"
3215
- say "install it with: curl -LsSf https://astral.sh/uv/install.sh | sh"
3216
- return 0
3217
- }
3218
- local a adapters missing=0
3219
- adapters=$(cfg_get_list harlequin.adapters "postgres snowflake")
3220
- local -a with=()
3221
- for a in $adapters; do with+=(--with "harlequin-$a"); done
3222
-
3223
- # uv records what it installed in a receipt beside the environment.
3224
- # `uv tool install` on an already-installed tool does nothing at all, so an
3225
- # adapter added to the config would never arrive without --force. Compare the
3226
- # receipt against the config and force only when they differ.
3227
- local receipt="${UV_TOOL_DIR:-$HOME/.local/share/uv/tools}/harlequin/uv-receipt.toml"
3228
- if have harlequin && [ -f "$receipt" ]; then
3229
- for a in $adapters; do
3230
- grep -q "name = \"harlequin-$a\"" "$receipt" || missing=1
3231
- done
3232
- [ "$missing" = "0" ] && { say "harlequin already installed"; return 0; }
3233
- say "harlequin: adapters changed, reinstalling"
3234
- uv tool install --force harlequin "${with[@]}" >/dev/null 2>&1 \
3235
- && say "harlequin reinstalled with: $(echo $adapters)" \
3236
- || say "harlequin reinstall failed, run: uv tool install --force harlequin"
3237
- return 0
3238
- fi
3239
- uv tool install harlequin "${with[@]}" >/dev/null 2>&1 \
3240
- && say "harlequin installed with: $(echo $adapters)" \
3241
- || say "harlequin install failed, run: uv tool install harlequin"
3242
- }
3243
-
3244
- apply_lens_extra_tools() {
3245
- # A small machine-wide pi-lens toolbelt for common project ecosystems.
3246
- # Pi-lens can fall back to npx or prompt for some tools, but putting these
3247
- # on PATH makes them available in any repo without per-project setup.
3248
- if [ "$(cfg_get pi.lens_extra_tools.python false)" = "true" ]; then
3249
- install_uv_tool black black
3250
- install_uv_tool mypy mypy
3251
- install_npm_tool pyright pyright
3252
- fi
3253
- if [ "$(cfg_get pi.lens_extra_tools.docker false)" = "true" ]; then
3254
- install_plain_github_binary hadolint hadolint/hadolint Linux-x86_64
3255
- install_npm_tool docker-langserver dockerfile-language-server-nodejs
3256
- fi
3257
- if [ "$(cfg_get pi.lens_extra_tools.github_actions false)" = "true" ]; then
3258
- install_binary actionlint rhysd/actionlint linux_amd64
3259
- install_uv_tool zizmor zizmor
3260
- fi
3261
- if [ "$(cfg_get pi.lens_extra_tools.terraform false)" = "true" ]; then
3262
- install_binary terraform-ls hashicorp/terraform-ls linux_amd64
3263
- install_binary tflint terraform-linters/tflint linux_amd64
3264
- install_plain_github_binary terragrunt gruntwork-io/terragrunt linux_amd64
3265
- fi
3266
- }
3267
-
3268
- apply_ghdash() {
3269
- have gh || { say "gh not installed, skipping dashboard"; return 0; }
3270
- gh extension list 2>/dev/null | grep -q gh-dash || gh extension install dlvhdr/gh-dash >/dev/null 2>&1
3271
- mkdir -p "$(dirname "$GHDASH_CFG")"
3272
- ACSTACK_CFG="$CFG" py "$GHDASH_CFG" <<'PY'
3273
- import os, sys, yaml
3274
- src, dst = os.environ["ACSTACK_CFG"], sys.argv[1]
3275
- # `or {}` on the section as well as the file. A bare "gh_dash:" with nothing
3276
- # under it, which is what commenting the block out leaves behind, parses to
3277
- # None, and every tw.get below then raises AttributeError. apply_ghdash died
3278
- # on that with a traceback and wrote no config at all. The keybindings and
3279
- # remote lookups below already guard the same way.
3280
- tw = ((yaml.safe_load(open(src)) or {}).get("gh_dash") or {}) if os.path.isfile(src) else {}
3281
- cfg = yaml.safe_load(open(dst)) or {} if os.path.isfile(dst) else {}
3282
- if tw.get("sections"):
3283
- cfg["prSections"] = [{"title": s["title"], "filters": s["filters"]} for s in tw["sections"]]
3284
- if tw.get("repo_paths"):
3285
- cfg["repoPaths"] = tw["repo_paths"]
3286
- kb = tw.get("keybindings", {}) or {}
3287
- # Every other setting reaches this script through cfg_get, which carries its
3288
- # own default. This block reads the yaml itself, so a machine with no
3289
- # terminal-workspace config used to land here with an empty dict: the prune
3290
- # below dropped the review and agent keys, and nothing was written back. D
3291
- # and S went silently dead while o, which already defaulted, kept working.
3292
- # Defaults here, matching config.example.yaml. An empty string still disables.
3293
- review = kb.get("review", "D")
3294
- agent_review = kb.get("agent_review", "S")
3295
- # "tree diff" is the old name for the review key, kept here so re-applying
3296
- # over a config written before the rename drops it rather than leaving two
3297
- # bindings on the same key.
3298
- MANAGED = ("review (tuicr)", "tree diff", "agent review", "open in browser")
3299
- prs = [k for k in (cfg.get("keybindings", {}) or {}).get("prs", [])
3300
- if k.get("name") not in MANAGED]
3301
- if review:
3302
- prs.append({"key": review, "name": "review (tuicr)",
3303
- "command": "cd {{.RepoPath}} && tuicr pr {{.PrNumber}}\n"})
3304
- if agent_review:
3305
- prs.append({"key": agent_review, "name": "agent review",
3306
- "command": 'cd {{.RepoPath}} && claude "/code-review {{.PrNumber}}"\n'})
3307
- kbs = cfg.setdefault("keybindings", {})
3308
- # o built into gh-dash opens through $BROWSER, whose output it discards. That
3309
- # is enough for a machine with a desktop and nothing at all for one without,
3310
- # where the only way to reach a browser is to hand the terminal a link. Take
3311
- # the key so acstack-open gets a terminal to draw on. GH_HOST covers
3312
- # GitHub Enterprise, whose URLs are the same shape on a different host.
3313
- open_key = (tw.get("remote", {}) or {}).get("open_key", "o")
3314
- if open_key:
3315
- host = "https://${GH_HOST:-github.com}"
3316
- prs.append({"key": open_key, "name": "open in browser",
3317
- "command": f'acstack-open "{host}/{{{{.RepoName}}}}/pull/{{{{.PrNumber}}}}"\n'})
3318
- issues = [k for k in (cfg.get("keybindings", {}) or {}).get("issues", [])
3319
- if k.get("name") not in MANAGED]
3320
- issues.append({"key": open_key, "name": "open in browser",
3321
- "command": f'acstack-open "{host}/{{{{.RepoName}}}}/issues/{{{{.IssueNumber}}}}"\n'})
3322
- kbs["issues"] = issues
3323
- kbs["prs"] = prs
3324
- if tw.get("page_keys", True):
3325
- uni = [k for k in kbs.get("universal", []) if k.get("builtin") not in ("pageUp", "pageDown")]
3326
- uni += [{"key": "pgup", "builtin": "pageUp"}, {"key": "pgdown", "builtin": "pageDown"}]
3327
- kbs["universal"] = uni
3328
- yaml.safe_dump(cfg, open(dst, "w"), sort_keys=False, default_flow_style=False, width=200)
3329
- print(" gh-dash config written")
3330
- PY
3331
- }
3332
-
3333
- # Every edit the pi component makes to pi's own files goes through here, and
3334
- # so does the record of what it changed. pi's config is JSON, which has no
3335
- # comment to mark a managed region with, so ownership lives in $PI_OWNED.
3336
- # pkg <identity> the configured source pi holds for a package
3337
- # own|disown pkg|file <name> record or forget one thing this setup added
3338
- # owns pkg|file <name> exit 0 when this setup added it
3339
- # parses <file> exit 0 when the file is absent or valid JSON
3340
- # merge <file> <slot> <json> claim each dotted key with a value, and release
3341
- # each one set to null, putting back what it held
3342
- # pin <identity> <prior> <ours> record a pin apply moved, keeping the first prior
3343
- # pin-get <identity> the prior and our pin, tab-separated
3344
- # pin-forget <identity> drop that record
3345
- # md write|strip the delegation block in pi's context file
3346
- pi_state() {
3347
- ACSTACK_PI_OWNED="$PI_OWNED" ACSTACK_PI_SETTINGS="$PI_SETTINGS" \
3348
- ACSTACK_PI_DIR="$PI_DIR" ACSTACK_PI_MD="$PI_MD_BEGIN|$PI_MD_END|$PI_DELEGATION" \
3349
- py "$@" <<'PY'
3350
- import json, os, re, sys
3351
-
3352
- OWNED = os.environ["ACSTACK_PI_OWNED"]
3353
- MISSING = object()
3354
-
3355
- def load_owned():
3356
- try:
3357
- d = json.load(open(OWNED))
3358
- except (OSError, ValueError):
3359
- d = {}
3360
- return d if isinstance(d, dict) else {}
3361
-
3362
- def save_owned(d):
3363
- d = {k: v for k, v in d.items() if v}
3364
- if not d:
3365
- if os.path.exists(OWNED):
3366
- os.remove(OWNED)
3367
- return
3368
- os.makedirs(os.path.dirname(OWNED), exist_ok=True)
3369
- json.dump(d, open(OWNED, "w"), indent=2)
3370
-
3371
- def load_json(path):
3372
- if not os.path.exists(path):
3373
- return {}
3374
- d = json.load(open(path))
3375
- if not isinstance(d, dict):
3376
- raise ValueError("not an object")
3377
- return d
3378
-
3379
- def identity(src):
3380
- # pi names a package by its npm name or its repository without the ref, so
3381
- # two pins of one repository are one package.
3382
- if isinstance(src, dict):
3383
- src = src.get("source", "")
3384
- if src.startswith("npm:"):
3385
- spec = src[4:]
3386
- at = spec.rfind("@")
3387
- return "npm:" + (spec[:at] if at > 0 else spec)
3388
- m = re.match(r"^(?:git:|https?://|ssh://)(?:git@)?(.+?)(?:\.git)?(?:@[^/@]+)?$", src)
3389
- if m:
3390
- return "git:" + m.group(1).replace(":", "/")
3391
- return src
3392
-
3393
- cmd, args = sys.argv[1], sys.argv[2:]
3394
-
3395
- if cmd == "pkg":
3396
- try:
3397
- pkgs = load_json(os.environ["ACSTACK_PI_SETTINGS"]).get("packages") or []
3398
- except ValueError:
3399
- raise SystemExit(1)
3400
- for p in pkgs:
3401
- if identity(p) == args[0]:
3402
- print(p.get("source") if isinstance(p, dict) else p)
3403
- raise SystemExit
3404
- raise SystemExit(1)
3405
-
3406
- if cmd in ("own", "disown", "owns"):
3407
- kind, name = args
3408
- slot = {"pkg": "packages", "file": "files"}[kind]
3409
- d = load_owned()
3410
- have = d.get(slot, [])
3411
- if cmd == "owns":
3412
- raise SystemExit(0 if name in have else 1)
3413
- have = [x for x in have if x != name] + ([name] if cmd == "own" else [])
3414
- d[slot] = have
3415
- save_owned(d)
3416
- raise SystemExit
3417
-
3418
- if cmd in ("pin", "pin-get", "pin-forget"):
3419
- d = load_owned()
3420
- pins = d.setdefault("pins", {})
3421
- if cmd == "pin":
3422
- ident, was, ours = args
3423
- pins[ident] = {"prior": pins.get(ident, {}).get("prior", was), "ours": ours}
3424
- elif cmd == "pin-forget":
3425
- pins.pop(args[0], None)
3426
- else:
3427
- rec = pins.get(args[0])
3428
- if not rec:
3429
- raise SystemExit(1)
3430
- print("%s\t%s" % (rec["prior"], rec["ours"]))
3431
- raise SystemExit
3432
- save_owned(d)
3433
- raise SystemExit
3434
-
3435
- if cmd == "parses":
3436
- try:
3437
- load_json(args[0])
3438
- except ValueError:
3439
- raise SystemExit(1)
3440
- raise SystemExit
3441
-
3442
- if cmd == "merge":
3443
- path, slot, want = args[0], args[1], json.loads(args[2])
3444
- try:
3445
- d = load_json(path)
3446
- except ValueError:
3447
- print(" pi: %s does not parse, so it was left alone" % path)
3448
- raise SystemExit(3)
3449
- owned = load_owned()
3450
- mine = owned.setdefault(slot, {})
3451
- # What a claimed key held before apply first changed it, so release can
3452
- # put the user's value back rather than delete it.
3453
- prior = owned.setdefault(slot + "_prior", {})
3454
- wrote, dropped = [], []
3455
- for key, value in want.items():
3456
- parents, leaf = ([], key) if slot == "keybindings" else (key.split(".")[:-1], key.split(".")[-1])
3457
- node = d
3458
- for p in parents:
3459
- if node.get(p, MISSING) is MISSING and value is not None:
3460
- node[p] = {}
3461
- node = node.get(p)
3462
- if not isinstance(node, dict):
3463
- break
3464
- if not isinstance(node, dict):
3465
- # A parent the user set to something other than an object, or
3466
- # deleted. Writing through it would replace their value, so
3467
- # concede this key. On release there is nothing left of ours.
3468
- if value is not None:
3469
- print(" pi: %s in %s is not an object, %s left alone" % (".".join(parents), path, key))
3470
- else:
3471
- mine.pop(key, None)
3472
- prior.pop(key, None)
3473
- continue
3474
- have = node.get(leaf, MISSING)
3475
- if value is None:
3476
- # Released. Delete only a value this setup wrote and nobody has
3477
- # changed since; anything else is the user's.
3478
- if key in mine and have == mine[key]:
3479
- if key in prior:
3480
- node[leaf] = prior[key]
3481
- else:
3482
- del node[leaf]
3483
- cur = d
3484
- for p in parents[:-1]:
3485
- cur = cur[p]
3486
- if parents and not node:
3487
- del cur[parents[-1]]
3488
- dropped.append(key)
3489
- mine.pop(key, None)
3490
- prior.pop(key, None)
3491
- elif have != value:
3492
- if key not in mine and have is not MISSING:
3493
- prior[key] = have
3494
- node[leaf] = value
3495
- mine[key] = value
3496
- wrote.append(key)
3497
- save_owned(owned)
3498
- if wrote or dropped:
3499
- if d or not os.path.exists(path):
3500
- os.makedirs(os.path.dirname(path), exist_ok=True)
3501
- with open(path, "w") as f:
3502
- # pi writes its own settings unescaped, so a value in any
3503
- # script round-trips as the user typed it.
3504
- json.dump(d, f, indent=2, ensure_ascii=False)
3505
- f.write("\n")
3506
- else:
3507
- # Everything in it was ours, and an empty file is no setting at all.
3508
- os.remove(path)
3509
- if wrote:
3510
- print(" pi: set %s in %s" % (", ".join(wrote), path))
3511
- if dropped:
3512
- print(" pi: released %s in %s" % (", ".join(dropped), path))
3513
- raise SystemExit
3514
-
3515
- if cmd == "md":
3516
- begin, end, line = os.environ["ACSTACK_PI_MD"].split("|")
3517
- agent_dir = os.environ["ACSTACK_PI_DIR"]
3518
- # pi reads one context file from its agent directory, the first of these
3519
- # that exists. A block in any other one would never reach the model.
3520
- names = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]
3521
- paths = [os.path.join(agent_dir, n) for n in names]
3522
- target = next((p for p in paths if os.path.isfile(p)), paths[1])
3523
- block = re.compile(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", re.S)
3524
- # The blank line write puts before the block goes with it, so stripping
3525
- # hands back the user's text exactly as it was.
3526
- gap = re.compile(r"\n?" + block.pattern, re.S)
3527
- state = load_owned()
3528
- owned_md = state.get("md", {})
3529
- for p in paths:
3530
- if not os.path.isfile(p) or (args[0] == "write" and p == target):
3531
- continue
3532
- t = open(p).read()
3533
- # write records the exact separator it added. Removing that and no
3534
- # more hands back the user's file byte for byte, final newline or not.
3535
- sep = owned_md.get("sep")
3536
- cut = re.compile(re.escape(sep) + block.pattern, re.S) if sep is not None else gap
3537
- stripped = cut.sub("", t)
3538
- if stripped != t:
3539
- if stripped.strip():
3540
- open(p, "w").write(stripped)
3541
- else:
3542
- os.remove(p)
3543
- print(" pi: delegation line removed from %s" % p)
3544
- if args[0] == "strip":
3545
- state.pop("md", None)
3546
- save_owned(state)
3547
- if args[0] == "write":
3548
- t = open(target).read() if os.path.isfile(target) else ""
3549
- mine = "%s\n%s\n%s\n" % (begin, line, end)
3550
- if block.search(t):
3551
- new = block.sub(lambda m: mine, t, count=1)
3552
- else:
3553
- sep = ("\n" if t and not t.endswith("\n") else "") + ("\n" if t else "")
3554
- new = t + sep + mine
3555
- state["md"] = {"sep": sep}
3556
- save_owned(state)
3557
- if new != t:
3558
- os.makedirs(agent_dir, exist_ok=True)
3559
- open(target, "w").write(new)
3560
- print(" pi: delegation line written to %s" % target)
3561
- raise SystemExit
3562
- PY
3563
- }
3564
-
3565
- # The four personal settings, from the config, as the JSON merge reads them.
3566
- # An empty value releases the key rather than writing an empty one.
3567
- pi_settings_wanted() {
3568
- ACSTACK_CFG="$CFG" py <<'PY'
3569
- import json, os, sys
3570
- import yaml
3571
- p = os.environ["ACSTACK_CFG"]
3572
- c = (yaml.safe_load(open(p)) or {}) if os.path.isfile(p) else {}
3573
- s = ((c.get("pi") or {}).get("settings") or {})
3574
- # config key -> (pi's dotted key, the default config.example.yaml ships, the
3575
- # values pi accepts). YAML reads a bare off as false, so mermaid maps it back.
3576
- KEYS = {"hide_thinking_block": ("hideThinkingBlock", True, (True, False)),
3577
- "mermaid": ("markdown.mermaid", "streaming", ("off", "final", "streaming")),
3578
- "install_telemetry": ("enableInstallTelemetry", False, (True, False)),
3579
- "theme": ("theme", "alex-mid-grey", None)}
3580
- out = {}
3581
- for k, (key, default, allowed) in KEYS.items():
3582
- v = s.get(k, default)
3583
- if v in ("", None):
3584
- out[key] = None
3585
- continue
3586
- if k == "mermaid" and v is False:
3587
- v = "off"
3588
- ok = (v in allowed and type(v) is type(allowed[0])) if allowed else isinstance(v, str)
3589
- if not ok:
3590
- # Left out entirely, so pi keeps whatever it holds now.
3591
- print(" pi: settings.%s cannot be %r, left alone" % (k, v), file=sys.stderr)
3592
- continue
3593
- out[key] = v
3594
- print(json.dumps(out))
3595
- PY
3596
- }
3597
-
3598
- pi_zentui_wanted() {
3599
- cat <<'JSON'
3600
- {
3601
- "components.editor.enabled": true,
3602
- "components.editor.style": "accent-rail",
3603
- "components.editor.styles.accent-rail.transparent": false,
3604
- "components.userMessages.enabled": false,
3605
- "components.footer.style": "starship",
3606
- "components.footer.modelLabel": "id",
3607
- "components.footer.styles.starship.segments.modelInfo": true,
3608
- "components.footer.styles.starship.extensionStatuses.defaultPlacement": "off",
3609
- "components.extensionStatuses.defaultVisibility": "hide",
3610
- "components.extensionStatuses.visibility.pi-lens": "hide",
3611
- "components.extensionStatuses.visibility.pi-lens-lsp": "hide",
3612
- "components.extensionStatuses.visibility.ponytail": "hide",
3613
- "components.footer.styles.starship.responsive": true,
3614
- "components.footer.styles.starship.format": "$cwd( in $session_name)( on $git_branch)( $git_status)( via $runtime)$fill$model($sep$provider)($sep$thinkingLevel)($sep$context)( $auto_compaction)($sep$tokens)( $cache_read)( $cache_write)($sep$cost)( $subscription)",
3615
- "components.footer.styles.starship.compactFormat": "$cwd$wrap(in $session_name)$wrap(on $git_branch) $git_status$fill$model$wrap_sep($provider)$wrap_sep($thinkingLevel)$wrap_sep$context$wrap_sep$tokens"
3616
- }
3617
- JSON
3618
- }
3619
-
3620
- pi_zentui_release() {
3621
- cat <<'JSON'
3622
- {
3623
- "components.editor.enabled": null,
3624
- "components.editor.style": null,
3625
- "components.editor.styles.accent-rail.transparent": null,
3626
- "components.userMessages.enabled": null,
3627
- "components.footer.style": null,
3628
- "components.footer.modelLabel": null,
3629
- "components.footer.styles.starship.segments.modelInfo": null,
3630
- "components.footer.styles.starship.extensionStatuses.defaultPlacement": null,
3631
- "components.extensionStatuses.defaultVisibility": null,
3632
- "components.extensionStatuses.visibility.pi-lens": null,
3633
- "components.extensionStatuses.visibility.pi-lens-lsp": null,
3634
- "components.extensionStatuses.visibility.ponytail": null,
3635
- "components.footer.styles.starship.responsive": null,
3636
- "components.footer.styles.starship.format": null,
3637
- "components.footer.styles.starship.compactFormat": null
3638
- }
3639
- JSON
3640
- }
3641
-
3642
- pi_write_theme() {
3643
- if [ -e "$PI_THEME" ] && ! pi_state owns file "$PI_THEME"; then
3644
- say "pi: $PI_THEME already exists, so the Mapflow theme was left alone"
3645
- return 0
3646
- fi
3647
- MAPFLOW_PI_THEME="$PI_THEME" py <<'PY'
3648
- import json, os
3649
-
3650
- target = os.environ["MAPFLOW_PI_THEME"]
3651
- theme = {
3652
- "$schema": "https://pi.dev/schemas/theme.schema.json",
3653
- "name": "alex-mid-grey",
3654
- "vars": {
3655
- "surfaceMuted": "#21262D",
3656
- "border": "#30363D",
3657
- "borderSubtle": "#21262D",
3658
- "text": "#C9D1D9",
3659
- "textBright": "#E6EDF3",
3660
- "muted": "#8B949E",
3661
- "dim": "#6E7681",
3662
- "toolStatus": "#59616B",
3663
- "blue": "#58A6FF",
3664
- "blueSoft": "#79C0FF",
3665
- "inlineCode": "#D2A8FF",
3666
- "green": "#3FB950",
3667
- "red": "#F85149",
3668
- "yellow": "#E0A052",
3669
- "orange": "#FFA657",
3670
- "purple": "#D2A8FF",
3671
- "toolAccent": "#95A7FF",
3672
- "toolName": "#8B85D6",
3673
- "toolText": "#98A1AC",
3674
- "pink": "#FF7B72",
3675
- "userPromptBg": "#24272e",
3676
- },
3677
- "colors": {
3678
- "accent": "toolAccent",
3679
- "border": "border",
3680
- "borderAccent": "toolStatus",
3681
- "borderMuted": "borderSubtle",
3682
- "success": "green",
3683
- "error": "red",
3684
- "warning": "yellow",
3685
- "muted": "muted",
3686
- "dim": "dim",
3687
- "text": "text",
3688
- "thinkingText": "toolText",
3689
- "selectedBg": "surfaceMuted",
3690
- "searchMatchBg": "#3B2E10",
3691
- "searchMatchText": "textBright",
3692
- "userMessageBg": "userPromptBg",
3693
- "userMessageText": "textBright",
3694
- "customMessageBg": "",
3695
- "customMessageText": "text",
3696
- "customMessageLabel": "muted",
3697
- "toolPendingBg": "",
3698
- "toolSuccessBg": "",
3699
- "toolErrorBg": "#211414",
3700
- "toolTitle": "toolName",
3701
- "toolOutput": "toolText",
3702
- "scrollbarTrack": "borderSubtle",
3703
- "scrollbarThumb": "border",
3704
- "mdHeading": "textBright",
3705
- "mdLink": "blue",
3706
- "mdLinkUrl": "dim",
3707
- "mdCode": "inlineCode",
3708
- "mdCodeBlock": "text",
3709
- "mdCodeBlockBorder": "#0D1117",
3710
- "mdQuote": "toolText",
3711
- "mdQuoteBorder": "border",
3712
- "mdHr": "borderSubtle",
3713
- "mdListBullet": "muted",
3714
- "toolDiffAdded": "#45B85A",
3715
- "toolDiffRemoved": "#E45D57",
3716
- "toolDiffContext": "dim",
3717
- "syntaxComment": "dim",
3718
- "syntaxKeyword": "pink",
3719
- "syntaxFunction": "purple",
3720
- "syntaxVariable": "text",
3721
- "syntaxString": "blueSoft",
3722
- "syntaxNumber": "orange",
3723
- "syntaxType": "orange",
3724
- "syntaxOperator": "muted",
3725
- "syntaxPunctuation": "muted",
3726
- "thinkingOff": "borderSubtle",
3727
- "thinkingMinimal": "border",
3728
- "thinkingLow": "dim",
3729
- "thinkingMedium": "muted",
3730
- "thinkingHigh": "blue",
3731
- "thinkingXhigh": "blueSoft",
3732
- "thinkingMax": "#9FCFF8",
3733
- "bashMode": "green",
3734
- },
3735
- "export": {"pageBg": "#0D1117", "cardBg": "#161B22", "infoBg": "#1C2128"},
3736
- }
3737
- os.makedirs(os.path.dirname(target), exist_ok=True)
3738
- json.dump(theme, open(target, "w"), indent=2, ensure_ascii=False)
3739
- open(target, "a").write("\n")
3740
- PY
3741
- pi_state own file "$PI_THEME"
3742
- say "pi: Mapflow theme written to $PI_THEME"
3743
- }
3744
-
3745
- # npm installs pi into its global bin, which a fresh shell may not have on
3746
- # PATH. Asking npm costs a process, so only when pi is not already found.
3747
- find_pi() {
3748
- have pi || ! have npm || PATH="$PATH:$(npm prefix -g 2>/dev/null)/bin"
3749
- }
3750
-
3751
-
3752
- # Pi's bundled TUI does not always receive Windows Terminal paste as plain
3753
- # text. Through Herdr and ConPTY it can arrive as Win32 input records such as
3754
- # ESC[13;28;13;1;0;1_. Patch the installed bundle after install or update.
3755
- patch_pi_win32_paste() {
3756
- have pi || return 0
3757
- local pi_bin pi_real root chunks
3758
- pi_bin=$(command -v pi) || return 0
3759
- pi_real=$(readlink -f "$pi_bin" 2>/dev/null || printf '%s' "$pi_bin")
3760
- case "$pi_real" in
3761
- */dist/bundle/cli.js) root=${pi_real%/dist/bundle/cli.js} ;;
3762
- *)
3763
- root="${pi_real%/bin/pi}/lib/node_modules/@earendil-works/pi-coding-agent"
3764
- if [ ! -d "$root/dist/bundle/chunks" ]; then
3765
- have npm || { say "pi: npm not on PATH, so Win32 paste patch was skipped"; return 0; }
3766
- root="$(npm root -g 2>/dev/null)/@earendil-works/pi-coding-agent"
3767
- fi
3768
- ;;
3769
- esac
3770
- chunks="$root/dist/bundle/chunks"
3771
- [ -d "$chunks" ] || { say "pi: bundle chunks not found, so Win32 paste patch was skipped"; return 0; }
3772
- SPECHUB_PI_CHUNKS="$chunks" py <<'PI_PATCH_PY'
3773
- import os, pathlib
3774
- chunks = pathlib.Path(os.environ["SPECHUB_PI_CHUNKS"])
3775
- target = None
3776
- for p in chunks.glob("*.js"):
3777
- try:
3778
- s = p.read_text()
3779
- except (OSError, UnicodeDecodeError):
3780
- continue
3781
- if "function decodePrintableKey(data)" in s and "handlePaste(pastedText)" in s:
3782
- target = p
3783
- break
3784
- if target is None:
3785
- print(" pi: TUI bundle not found, so Win32 paste patch was skipped")
3786
- raise SystemExit
3787
- s = target.read_text()
3788
- changed = False
3789
- repls = [
3790
- ('function isKeyRelease(data){return data.includes("\\x1B[200~")?!1:!!(data.includes(":3u")||data.includes(":3~")||data.includes(":3A")||data.includes(":3B")||data.includes(":3C")||data.includes(":3D")||data.includes(":3H")||data.includes(":3F"))}',
3791
- 'function isKeyRelease(data){let win32=parseWin32InputRecord(data);if(win32)return!win32.keyDown;return data.includes("\\x1B[200~")?!1:!!(data.includes(":3u")||data.includes(":3~")||data.includes(":3A")||data.includes(":3B")||data.includes(":3C")||data.includes(":3D")||data.includes(":3H")||data.includes(":3F"))}'),
3792
- ('function parseModifyOtherKeysSequence(data){let match=data.match(/^\\x1b\\[27;(\\d+);(\\d+)~$/);if(!match)return null;let modValue=parseInt(match[1],10);return{codepoint:parseInt(match[2],10),modifier:modValue-1}}',
3793
- 'function parseModifyOtherKeysSequence(data){let match=data.match(/^\\x1b\\[27;(\\d+);(\\d+)~$/);if(!match)return null;let modValue=parseInt(match[1],10);return{codepoint:parseInt(match[2],10),modifier:modValue-1}}function parseWin32InputRecord(data){let match=data.match(/^\\x1b\\[(\\d+);(\\d+);(-?\\d+);([01]);(\\d+);(\\d+)_$/);if(!match)return null;let virtualKey=Number.parseInt(match[1],10),scanCode=Number.parseInt(match[2],10),codepoint=Number.parseInt(match[3],10),keyDown=match[4]==="1",controlState=Number.parseInt(match[5],10),modifier=0;if((controlState&16)!==0)modifier|=MODIFIERS.shift;if((controlState&(1|2))!==0)modifier|=MODIFIERS.alt;if((controlState&(4|8))!==0)modifier|=MODIFIERS.ctrl;if((controlState&32)!==0)modifier|=128;if((controlState&128)!==0)modifier|=64;return{virtualKey,scanCode,codepoint,keyDown,modifier}}'),
3794
- ('function parseKey(data){let kitty=parseKittySequence(data);',
3795
- 'function parseKey(data){let win32=parseWin32InputRecord(data);if(win32){_lastEventType=win32.keyDown?"press":"release";if(!win32.keyDown)return;let codepoint=win32.codepoint;if(win32.virtualKey===46)codepoint=FUNCTIONAL_CODEPOINTS.delete;else if(win32.virtualKey===45)codepoint=FUNCTIONAL_CODEPOINTS.insert;else if(win32.virtualKey===33)codepoint=FUNCTIONAL_CODEPOINTS.pageUp;else if(win32.virtualKey===34)codepoint=FUNCTIONAL_CODEPOINTS.pageDown;else if(win32.virtualKey===35)codepoint=FUNCTIONAL_CODEPOINTS.end;else if(win32.virtualKey===36)codepoint=FUNCTIONAL_CODEPOINTS.home;else if(win32.virtualKey===37)codepoint=ARROW_CODEPOINTS.left;else if(win32.virtualKey===38)codepoint=ARROW_CODEPOINTS.up;else if(win32.virtualKey===39)codepoint=ARROW_CODEPOINTS.right;else if(win32.virtualKey===40)codepoint=ARROW_CODEPOINTS.down;else if(win32.virtualKey===8)codepoint=CODEPOINTS.backspace;else if(win32.virtualKey===9)codepoint=CODEPOINTS.tab;else if(win32.virtualKey===13)codepoint=CODEPOINTS.enter;else if(win32.virtualKey===27)codepoint=CODEPOINTS.escape;return formatParsedKey(codepoint,win32.modifier)}let kitty=parseKittySequence(data);'),
3796
- ('function decodeModifyOtherKeysPrintable(data){let parsed=parseModifyOtherKeysSequence(data);if(!(!parsed||(parsed.modifier&~LOCK_MASK&~MODIFIERS.shift)!==0)&&!(!Number.isFinite(parsed.codepoint)||parsed.codepoint<32))try{return String.fromCodePoint(parsed.codepoint)}catch{return}}function decodePrintableKey(data){return decodeKittyPrintable(data)??decodeModifyOtherKeysPrintable(data)}',
3797
- 'function decodeModifyOtherKeysPrintable(data){let parsed=parseModifyOtherKeysSequence(data);if(!(!parsed||(parsed.modifier&~LOCK_MASK&~MODIFIERS.shift)!==0)&&!(!Number.isFinite(parsed.codepoint)||parsed.codepoint<32))try{return String.fromCodePoint(parsed.codepoint)}catch{return}}function decodeWin32Printable(data){let parsed=parseWin32InputRecord(data);if(!parsed||!parsed.keyDown)return;let modifier=parsed.modifier&~LOCK_MASK;if((modifier&(MODIFIERS.ctrl|MODIFIERS.alt|MODIFIERS.super))!==0)return;if(!Number.isFinite(parsed.codepoint)||parsed.codepoint<32)return;try{return String.fromCodePoint(parsed.codepoint)}catch{return}}function decodePrintableKey(data){return decodeKittyPrintable(data)??decodeModifyOtherKeysPrintable(data)??decodeWin32Printable(data)}'),
3798
- ('let decodedText=pastedText.replace(/\\x1b\\[(\\d+);5u/g,(match,code)=>{let cp=Number(code);return cp>=97&&cp<=122?String.fromCharCode(cp-96):cp>=65&&cp<=90?String.fromCharCode(cp-64):match}),filteredText=this.normalizeText(decodedText).split("").filter(char=>char===`\n`||char.charCodeAt(0)>=32).join("");',
3799
- 'let decodedText=pastedText.replace(/\\x1b\\[(\\d+);(\\d+);(-?\\d+);([01]);(\\d+);(\\d+)_/g,(match,vk,sc,uc,kd)=>{if(kd!=="1")return "";let cp=Number(uc),key=Number(vk);if(key===13||cp===13)return `\n`;if(cp>=32)try{return String.fromCodePoint(cp)}catch{return ""}return ""}).replace(/\\x1b\\[(\\d+);5u/g,(match,code)=>{let cp=Number(code);return cp>=97&&cp<=122?String.fromCharCode(cp-96):cp>=65&&cp<=90?String.fromCharCode(cp-64):match}),filteredText=this.normalizeText(decodedText).split("").filter(char=>char===`\n`||char.charCodeAt(0)>=32).join("");')]
3800
- missing = []
3801
- for old, new in repls:
3802
- if new in s:
3803
- continue
3804
- if old not in s:
3805
- missing.append(old[:80])
3806
- continue
3807
- s = s.replace(old, new, 1)
3808
- changed = True
3809
- if missing:
3810
- print(" pi: Win32 paste patch did not match this Pi bundle")
3811
- raise SystemExit
3812
- if changed:
3813
- try:
3814
- target.write_text(s)
3815
- except OSError as exc:
3816
- print(f" pi: could not patch {target} ({exc})")
3817
- raise SystemExit
3818
- print(" pi: Win32 paste patch applied")
3819
- else:
3820
- print(" pi: Win32 paste patch already applied")
3821
- PI_PATCH_PY
3822
- }
3823
-
3824
- # The ACStack version running this script, which is the one pi should pin.
3825
- acstack_version() {
3826
- json_field "$(dirname "$0")/../../.claude-plugin/plugin.json" version
3827
- }
3828
-
3829
- # Installs one pi package, or moves its pin. Ownership is taken only when the
3830
- # package was absent: one the user installed stays theirs, pin and all.
3831
- pi_add() { # pi_add <source> <identity>
3832
- local cur
3833
- cur=$(pi_state pkg "$2")
3834
- if [ -n "$cur" ]; then
3835
- # Unpinned, presence is enough: a pin the user chose stays.
3836
- case "$1" in "$2"@*) ;; *) say "pi: $2 already installed"; return 0 ;; esac
3837
- [ "$cur" = "$1" ] && { say "pi: $1 already installed"; return 0; }
3838
- fi
3839
- if ! pi install "$1" >/dev/null 2>&1; then
3840
- say "pi: install of $1 failed, run: pi install $1"
3841
- return 1
3842
- fi
3843
- if [ -n "$cur" ]; then
3844
- pi_state pin "$2" "$cur" "$1"
3845
- say "pi: $cur moved to $1"
3846
- else
3847
- pi_state own pkg "$2"
3848
- say "pi: $1 installed"
3849
- fi
3850
- }
3851
-
3852
- # Puts back a pin apply moved, while the pin is still the one apply set.
3853
- pi_unpin() { # pi_unpin <identity>
3854
- local rec prior ours cur
3855
- rec=$(pi_state pin-get "$1") || return 0
3856
- prior="${rec%%$'\t'*}" ours="${rec#*$'\t'}"
3857
- cur=$(pi_state pkg "$1")
3858
- if [ "$cur" = "$ours" ]; then
3859
- have pi || { say "pi: not on PATH, so $cur stays pinned"; return 0; }
3860
- pi install "$prior" >/dev/null 2>&1 \
3861
- || { say "pi: could not put back $prior, run: pi install $prior"; return 0; }
3862
- say "pi: $cur put back to $prior"
3863
- fi
3864
- pi_state pin-forget "$1"
3865
- }
3866
-
3867
- # Removes a package this setup installed. One the user installed is left.
3868
- pi_drop() { # pi_drop <identity>
3869
- local cur
3870
- pi_state owns pkg "$1" || return 0
3871
- cur=$(pi_state pkg "$1")
3872
- if [ -n "$cur" ]; then
3873
- have pi || { say "pi: not on PATH, so $cur stays installed"; return 0; }
3874
- pi remove "$cur" >/dev/null 2>&1 || { say "pi: remove of $cur failed, run: pi remove $cur"; return 0; }
3875
- say "pi: $cur removed"
3876
- fi
3877
- pi_state disown pkg "$1"
3878
- }
3879
-
3880
- # A package pi holds at a fixed version, such as npm:pi-subagents@1.0.0.
3881
- pi_npm_pin() { # pi_npm_pin <name>
3882
- local src
3883
- version_table | awk -F'\t' -v n="$1" '$1==n && $2=="pi" && $3=="npm"{found=1} END{exit !found}' \
3884
- || return 1
3885
- src=$(pi_state pkg "npm:$1") || return 1
3886
- [ "$src" != "npm:$1" ]
3887
- }
3888
-
3889
- pi_suggest_todo_discipline() {
3890
- local needle="When using a todo list, keep it current as work completes."
3891
- grep -qsF "$needle" "$PI_DIR"/AGENTS.override.md "$PI_DIR"/AGENTS.md "$PI_DIR"/AGENTS.MD \
3892
- "$PI_DIR"/CLAUDE.md "$PI_DIR"/CLAUDE.MD && return 0
3893
- say "pi: todo tool installed. For stricter todo discipline, ask the agent"
3894
- say "pi: running this setup to hand-edit $PI_DIR/AGENTS.md and add:"
3895
- say "pi: $needle Before reporting a task as done, check the todo list"
3896
- say "pi: and mark every completed step completed. Do not leave stale"
3897
- say "pi: in-progress or pending todos after a commit, PR, merge or workflow gate."
3898
- }
3899
-
3900
- # The pin apply would move to, or nothing when the installed one is current
3901
- # or newer. A newer pin is one the user chose, so it is never moved back.
3902
- pi_acstack_behind() {
3903
- local cur ver pin
3904
- ver=$(acstack_version); [ -n "$ver" ] || return 0
3905
- cur=$(pi_state pkg "$PI_ACSTACK") || return 0
3906
- case "$cur" in "$PI_ACSTACK"@*) pin="${cur##*@}" ;; *) return 0 ;; esac
3907
- [ "$pin" = "$ver" ] && return 0
3908
- [ "$(printf '%s\n%s\n' "$pin" "$ver" | sort -V | tail -1)" = "$ver" ] || return 0
3909
- printf '%s\t%s' "$pin" "$ver"
3910
- }
3911
-
3912
- bili_bin() {
3913
- if [ -x "$PI_DIR/npm/node_modules/.bin/bili" ]; then
3914
- printf '%s' "$PI_DIR/npm/node_modules/.bin/bili"
3915
- elif have bili; then
3916
- command -v bili
3917
- else
3918
- return 1
3919
- fi
3920
- }
3921
-
3922
- bili_status() { # bili_status <bin> <client> -> installed|absent|unknown
3923
- local line
3924
- line=$("$1" plugin list 2>/dev/null | awk -v c="$2" '$1==c{print; exit}') || true
3925
- [ -n "$line" ] || { echo unknown; return 0; }
3926
- case "$line" in *"not installed"*) echo absent ;; *) echo installed ;; esac
3927
- }
3928
-
3929
- bili_client() { # bili_client install|remove <client>
3930
- local op="$1" client="$2" bin marker status
3931
- marker="bili:$client"
3932
- if [ "$op" = "install" ]; then
3933
- [ "$client" = "pi" ] || have "$client" || return 0
3934
- pi_state owns file "$marker" && return 0
3935
- if bin=$(bili_bin); then
3936
- status=$(bili_status "$bin" "$client")
3937
- if [ "$status" = "installed" ]; then
3938
- say "pi: billion-context already enabled for $client"
3939
- return 0
3940
- fi
3941
- if "$bin" plugin install "$client" >/dev/null 2>&1; then
3942
- pi_state own file "$marker"
3943
- say "pi: billion-context enabled for $client"
3944
- else
3945
- say "pi: billion-context $client setup failed, run: bili plugin install $client"
3946
- fi
3947
- else
3948
- say "pi: bili command not found, so billion-context $client setup was skipped"
3949
- fi
3950
- elif pi_state owns file "$marker"; then
3951
- if bin=$(bili_bin); then
3952
- if "$bin" plugin remove "$client" >/dev/null 2>&1; then
3953
- pi_state disown file "$marker"
3954
- say "pi: billion-context disabled for $client"
3955
- else
3956
- say "pi: billion-context $client removal failed, run: bili plugin remove $client"
3957
- fi
3958
- else
3959
- say "pi: bili command not found, so billion-context $client setup was left installed"
3960
- fi
3961
- fi
3962
- }
3963
-
3964
- apply_pi() {
3965
- find_pi
3966
- if ! have pi; then
3967
- have npm || {
3968
- say "npm not installed, skipping pi"
3969
- say "install Node.js, then run apply again"
3970
- return 0
3971
- }
3972
- npm install -g @earendil-works/pi-coding-agent >/dev/null 2>&1 \
3973
- || { say "pi install failed, run: npm install -g @earendil-works/pi-coding-agent"; return 0; }
3974
- have pi || { say "pi installed, but npm's global bin is not on PATH"; return 0; }
3975
- say "pi installed"
3976
- fi
3977
- # pi rewrites settings.json on every install. Handing it a file it cannot
3978
- # parse risks the user's settings, so concede the whole component instead.
3979
- if ! pi_state parses "$PI_SETTINGS"; then
3980
- say "pi: $PI_SETTINGS does not parse, so nothing was installed into pi"
3981
- say "fix that file, then run apply again"
3982
- return 0
3983
- fi
3984
-
3985
- patch_pi_win32_paste
3986
-
3987
- # ACStack cannot launch a subagent on pi without pi-subagents.
3988
- pi_add npm:pi-subagents npm:pi-subagents
3989
- if [ "$(cfg_get pi.todo true)" = "true" ]; then
3990
- pi_add npm:@juicesharp/rpiv-todo npm:@juicesharp/rpiv-todo
3991
- pi_suggest_todo_discipline
3992
- else
3993
- pi_drop npm:@juicesharp/rpiv-todo
3994
- fi
3995
- if [ "$(cfg_get pi.plugin_manager true)" = "true" ]; then
3996
- pi_add npm:pi-plugin-manager npm:pi-plugin-manager
3997
- else
3998
- pi_drop npm:pi-plugin-manager
3999
- fi
4000
- # bili's proxy dies with the session that started it, cutting off every
4001
- # other Claude Code or Codex session. Keep it in pi; undo older installs.
4002
- bili_client remove codex
4003
- bili_client remove claude
4004
- if [ "$(cfg_get pi.billion_context true)" = "true" ]; then
4005
- pi_state merge "$BILLION_CONFIG" billion '{"pi.subagents": false}'
4006
- if [ "$(json_field "$BILLION_CONFIG" pi.subagents)" = "False" ]; then
4007
- pi_add "$PI_BILLION" "$PI_BILLION"
4008
- bili_client install pi
4009
- else
4010
- say "pi: billion-context acp_delegate could not be disabled, so billion-context setup was skipped"
4011
- fi
4012
- else
4013
- bili_client remove pi
4014
- pi_state merge "$BILLION_CONFIG" billion '{"pi.subagents": null}'
4015
- pi_drop "$PI_BILLION"
4016
- fi
4017
- if [ "$(cfg_get pi.question_tool true)" = "true" ]; then
4018
- pi_add npm:@juicesharp/rpiv-ask-user-question npm:@juicesharp/rpiv-ask-user-question
4019
- else
4020
- pi_drop npm:@juicesharp/rpiv-ask-user-question
4021
- fi
4022
- if [ "$(cfg_get pi.web_access true)" = "true" ]; then
4023
- pi_add npm:pi-web-access npm:pi-web-access
4024
- else
4025
- pi_drop npm:pi-web-access
4026
- fi
4027
- if [ "$(cfg_get pi.lens false)" = "true" ]; then
4028
- pi_add npm:pi-lens npm:pi-lens
4029
- else
4030
- pi_drop npm:pi-lens
4031
- fi
4032
- if [ "$(cfg_get pi.slash_anywhere true)" = "true" ]; then
4033
- pi_add "$PI_SLASH_ANYWHERE" "$PI_SLASH_ANYWHERE"
4034
- else
4035
- pi_drop "$PI_SLASH_ANYWHERE"
4036
- fi
4037
- local ver cur; ver=$(acstack_version)
4038
- if [ -n "$ver" ]; then
4039
- if cur=$(pi_state pkg "$PI_ACSTACK") && [ -z "$(pi_acstack_behind)" ]; then
4040
- say "pi: $cur already installed"
4041
- else
4042
- pi_add "$PI_ACSTACK@$ver" "$PI_ACSTACK"
4043
- fi
4044
- else
4045
- say "pi: no plugin version beside this script, so ACStack was not installed into pi"
4046
- fi
4047
- if [ "$(cfg_get pi.compact_tools true)" = "true" ]; then
4048
- pi_add npm:pi-compact-tools npm:pi-compact-tools
4049
- else
4050
- pi_drop npm:pi-compact-tools
4051
- fi
4052
- [ "$(cfg_get pi.settings.theme alex-mid-grey)" = "alex-mid-grey" ] && pi_write_theme
4053
- if [ "$(cfg_get pi.zentui true)" = "true" ]; then
4054
- pi_add "$PI_ZENTUI" "$PI_ZENTUI"
4055
- pi_state merge "$PI_ZENTUI_CFG" zentui "$(pi_zentui_wanted)"
4056
- else
4057
- pi_state merge "$PI_ZENTUI_CFG" zentui "$(pi_zentui_release)"
4058
- pi_drop "$PI_ZENTUI"
4059
- fi
4060
- if [ "$(cfg_get pi.hide_lens_lsp_status true)" = "true" ]; then
4061
- pi_state merge "$PI_LENS_CFG" pi_lens '{"ui.hideLspStatus": true, "widget.visible": false}'
4062
- else
4063
- pi_state merge "$PI_LENS_CFG" pi_lens '{"ui.hideLspStatus": null, "widget.visible": null}'
4064
- fi
4065
- apply_lens_extra_tools
4066
-
4067
- # agentic-retro writes its own extension file. This setup only asks it to,
4068
- # and owns the file only when it was not there before.
4069
- if [ "$(cfg_get pi.agentic_retro true)" = "true" ]; then
4070
- if have agentic-retro; then
4071
- local fresh=0; [ -e "$PI_RETRO_EXT" ] || fresh=1
4072
- if agentic-retro install-pi >/dev/null 2>&1; then
4073
- [ "$fresh" = "1" ] && pi_state own file "$PI_RETRO_EXT"
4074
- say "pi: agentic-retro extension written"
4075
- else
4076
- say "pi: agentic-retro install-pi failed, run it yourself"
4077
- fi
4078
- else
4079
- say "pi: agentic-retro not on PATH, so its pi extension was skipped"
4080
- say "install the agentic-retro plugin first, then run apply again"
4081
- fi
4082
- elif pi_state owns file "$PI_RETRO_EXT"; then
4083
- rm -f "$PI_RETRO_EXT"
4084
- pi_state disown file "$PI_RETRO_EXT"
4085
- say "pi: agentic-retro extension removed"
4086
- fi
4087
-
4088
- pi_state merge "$PI_SETTINGS" settings "$(pi_settings_wanted)"
4089
- pi_state merge "$PI_KEYBINDINGS" keybindings '{"app.message.dequeue": "ctrl+enter"}'
4090
- pi_state merge "$PI_SUBAGENT_CFG" subagent '{"toolActivation": "eager"}'
4091
- pi_state md write
4092
- patch_pi_win32_paste
4093
-
4094
- # Logging in is interactive and stores a credential, so it stays the user's.
4095
- [ -f "$PI_DIR/auth.json" ] || say "pi: not logged in yet. Run pi, then /login"
4096
- return 0
4097
- }
4098
-
4099
- # disable pi and uninstall both come here. It undoes what apply_pi recorded
4100
- # and leaves the pi binary, its logins and its sessions alone.
4101
- remove_pi() {
4102
- find_pi
4103
- if pi_state parses "$PI_SETTINGS"; then
4104
- pi_unpin "$PI_ACSTACK"
4105
- pi_drop "$PI_ACSTACK"
4106
- bili_client remove codex
4107
- bili_client remove claude
4108
- bili_client remove pi
4109
- pi_drop "$PI_BILLION"
4110
- pi_drop npm:pi-plugin-manager
4111
- pi_drop npm:@juicesharp/rpiv-todo
4112
- pi_drop npm:pi-compact-tools
4113
- pi_drop "$PI_ZENTUI"
4114
- pi_drop npm:@juicesharp/rpiv-ask-user-question
4115
- pi_drop npm:pi-web-access
4116
- pi_drop npm:pi-lens
4117
- pi_drop "$PI_SLASH_ANYWHERE"
4118
- pi_drop npm:pi-subagents
4119
- else
4120
- say "pi: $PI_SETTINGS does not parse, so its packages were left installed"
4121
- fi
4122
- pi_state merge "$BILLION_CONFIG" billion '{"pi.subagents": null}'
4123
- if pi_state owns file "$PI_RETRO_EXT"; then
4124
- rm -f "$PI_RETRO_EXT"
4125
- pi_state disown file "$PI_RETRO_EXT"
4126
- say "pi: agentic-retro extension removed"
4127
- fi
4128
- pi_state merge "$PI_SETTINGS" settings \
4129
- '{"hideThinkingBlock": null, "markdown.mermaid": null, "enableInstallTelemetry": null, "theme": null}'
4130
- pi_state merge "$PI_ZENTUI_CFG" zentui "$(pi_zentui_release)"
4131
- pi_state merge "$PI_LENS_CFG" pi_lens '{"ui.hideLspStatus": null, "widget.visible": null}'
4132
- if pi_state owns file "$PI_THEME"; then
4133
- rm -f "$PI_THEME"
4134
- pi_state disown file "$PI_THEME"
4135
- say "pi: Mapflow theme removed"
4136
- fi
4137
- pi_state merge "$PI_KEYBINDINGS" keybindings '{"app.message.dequeue": null}'
4138
- pi_state merge "$PI_SUBAGENT_CFG" subagent '{"toolActivation": null}'
4139
- pi_state md strip
4140
- return 0
4141
- }
4142
-
4143
- # Every tool with a version worth comparing.
4144
- # name | component key | source | reference | release asset match
4145
- # mermaid-ascii is deliberately absent: it has no version flag at all, so
4146
- # nothing here can read what is installed. `upgrade mermaid-ascii` still works,
4147
- # because upgrade_one carries an arm of its own for it. A tool that is in
4148
- # neither place is one upgrade cannot reach, whatever the docs promise.
4149
- version_table() {
4150
- cat <<'T'
4151
- delta delta github dandavison/delta x86_64-unknown-linux-gnu
4152
- diffnav diffnav github dlvhdr/diffnav Linux_x86_64
4153
- fzf diffnav github junegunn/fzf linux_amd64
4154
- lazygit lazygit github jesseduffield/lazygit linux_x86_64
4155
- tuicr tuicr github agavra/tuicr x86_64-unknown-linux-gnu
4156
- yazi yazi github sxyazi/yazi x86_64-unknown-linux-gnu
4157
- glow markdown github charmbracelet/glow Linux_x86_64
4158
- herdr herdr herdr - -
4159
- gh-dash gh_dash ghext dlvhdr/gh-dash -
4160
- harlequin harlequin pypi harlequin -
4161
- pi pi npm @earendil-works/pi-coding-agent -
4162
- pi-subagents pi npm pi-subagents -
4163
- billion-context pi npm billion-context -
4164
- @juicesharp/rpiv-todo pi npm @juicesharp/rpiv-todo -
4165
- pi-plugin-manager pi npm pi-plugin-manager -
4166
- @juicesharp/rpiv-ask-user-question pi npm @juicesharp/rpiv-ask-user-question -
4167
- pi-web-access pi npm pi-web-access -
4168
- pi-lens pi npm pi-lens -
4169
- pi-slash-anywhere pi npm pi-slash-anywhere -
4170
- pi-compact-tools pi npm pi-compact-tools -
4171
- pi-zentui pi npm pi-zentui -
4172
- actionlint pi github rhysd/actionlint linux_amd64
4173
- hadolint pi github hadolint/hadolint Linux-x86_64
4174
- terraform-ls pi github hashicorp/terraform-ls linux_amd64
4175
- tflint pi github terraform-linters/tflint linux_amd64
4176
- terragrunt pi github gruntwork-io/terragrunt linux_amd64
4177
- T
4178
- }
4179
-
4180
- installed_version() { # installed_version <name> -> a bare version, or nothing
4181
- local v=""
4182
- case "$1" in
4183
- # delta 0.19.2
4184
- delta) v=$(delta --version 2>/dev/null | awk '{print $2}') ;;
4185
- # an ASCII banner, then: version v0.12.0
4186
- diffnav) v=$(diffnav --version 2>/dev/null | sed -n 's/^version //p') ;;
4187
- # 0.74.3 (15f64c49)
4188
- fzf) v=$(fzf --version 2>/dev/null | awk '{print $1}') ;;
4189
- # commit=..., version=0.64.1, os=linux, arch=amd64, git version=2.43.0
4190
- # Split on the commas first: the line ends in git's own version, and one
4191
- # regex over the whole line reads that one instead of lazygit's.
4192
- lazygit) v=$(lazygit --version 2>/dev/null | tr ',' '\n' | sed -n 's/^ *version=//p') ;;
4193
- # glow version 3.0.0 (add5e7a)
4194
- glow) v=$(glow --version 2>/dev/null | awk '{print $3}') ;;
4195
- # tuicr 0.23.0
4196
- tuicr) v=$(tuicr --version 2>/dev/null | awk '{print $2}') ;;
4197
- # a block of lines, one of them: " Version: 26.8.15 (1f3588d 2026-08-15)"
4198
- yazi) v=$(yazi --version 2>/dev/null | sed -n 's/^ *Version: \([^ ]*\).*/\1/p') ;;
4199
- # herdr 0.8.2
4200
- herdr) v=$(herdr --version 2>/dev/null | awk '{print $2}') ;;
4201
- # gh dash<TAB>dlvhdr/gh-dash<TAB>v4.25.2
4202
- gh-dash) v=$(gh extension list 2>/dev/null | awk -F'\t' '$2=="dlvhdr/gh-dash"{print $3}') ;;
4203
- # harlequin v2.13.0, then an indented line per command it installed
4204
- harlequin) v=$(uv tool list 2>/dev/null | awk '$1=="harlequin"{print $2}') ;;
4205
- # 1.1.0
4206
- pi) find_pi; v=$(pi --version 2>/dev/null | awk '{print $NF}') ;;
4207
- # actionlint version 1.7.7
4208
- actionlint) v=$(actionlint -version 2>/dev/null | awk '{print $2}') ;;
4209
- # Haskell Dockerfile Linter 2.12.0
4210
- hadolint) v=$(hadolint --version 2>/dev/null | awk '{print $NF}') ;;
4211
- # terraform-ls version 0.36.5
4212
- terraform-ls) v=$(terraform-ls version 2>/dev/null | awk '{print $2}') ;;
4213
- # TFLint version 0.59.1
4214
- tflint) v=$(tflint --version 2>/dev/null | awk 'NR==1{print $3}') ;;
4215
- # terragrunt version v0.91.2
4216
- terragrunt) v=$(terragrunt --version 2>/dev/null | awk '{print $NF}') ;;
4217
- # pi installs npm packages under its own directory, not npm's global one.
4218
- pi-subagents|pi-compact-tools|pi-zentui|pi-web-access|pi-lens|pi-slash-anywhere|@juicesharp/rpiv-ask-user-question|billion-context|@juicesharp/rpiv-todo|pi-plugin-manager)
4219
- v=$(json_field "$PI_DIR/npm/node_modules/$1/package.json" version) ;;
4220
- esac
4221
- printf '%s' "${v#v}"
4222
- }
4223
-
4224
- # One JSON field, by dotted path, read from a file. install_binary hands its
4225
- # release document to python through the environment; this one cannot, because
4226
- # herdr's manifest carries a whole changelog and a document that size overflows
4227
- # the environment an exec is allowed. A file has no such ceiling.
4228
- json_field() { # json_field <file> <dotted.path>
4229
- py "$1" "$2" <<'PY'
4230
- import json, sys
4231
- try:
4232
- d = json.load(open(sys.argv[1]))
4233
- except (ValueError, OSError):
4234
- raise SystemExit
4235
- for k in sys.argv[2].split("."):
4236
- d = d.get(k) if isinstance(d, dict) else None
4237
- if d is None: raise SystemExit
4238
- print(d)
4239
- PY
4240
- }
4241
-
4242
- latest_version() { # latest_version <source> <reference> -> a version, or nothing
4243
- [ "${ACSTACK_TW_OFFLINE:-0}" = "1" ] && return 0
4244
- local url field tmp v="" tok="${GH_TOKEN:-${GITHUB_TOKEN:-}}"
4245
- local -a auth=()
4246
- case "$1" in
4247
- github|ghext)
4248
- url="https://api.github.com/repos/$2/releases/latest"; field=tag_name
4249
- # An unauthenticated GitHub call gets 60 an hour, shared with everything
4250
- # else on the machine. Ten tools in one run is a sixth of that. A token
4251
- # raises it to 5000 and costs nothing to pass. It goes to GitHub and
4252
- # nowhere else: the two hosts below never see it.
4253
- [ -n "$tok" ] && auth=(-H "Authorization: Bearer $tok") ;;
4254
- # herdr publishes its own manifest, and `herdr update` installs from it.
4255
- herdr) url="https://herdr.dev/latest.json"; field=version ;;
4256
- pypi) url="https://pypi.org/pypi/$2/json"; field=info.version ;;
4257
- # A scoped name keeps its @ and escapes its slash.
4258
- npm) url="https://registry.npmjs.org/${2/\//%2F}/latest"; field=version ;;
4259
- *) return 0 ;;
4260
- esac
4261
- tmp=$(mktemp)
4262
- curl -sSf "${auth[@]}" --max-time 10 "$url" -o "$tmp" 2>/dev/null \
4263
- && v=$(json_field "$tmp" "$field")
4264
- rm -f "$tmp"
4265
- printf '%s' "${v#v}"
4266
- }
4267
-
4268
- outdated_report() { # tab-separated rows on stdout, one per finding
4269
- local name key src ref inst latest
4270
- # Components that arrived since the last apply. See write_stamp for why this
4271
- # reads the stamp rather than the user's own config: a config that overrides
4272
- # four keys and defaults the rest is normal, and diffing against it would
4273
- # report every defaulted component as new.
4274
- if [ ! -f "$STAMP" ]; then
4275
- # No stamp and no managed block is a machine apply has never touched.
4276
- # Comparing versions there would report a workspace as current when none
4277
- # of it is installed, so say the one true thing and stop.
4278
- if ! grep -q "$BEGIN" "$HERDR_CFG" 2>/dev/null; then
4279
- printf 'missing\tworkspace\tnever applied on this machine\n'
4280
- return 0
4281
- fi
4282
- printf 'unknown\tcomponents\tno stamp yet, run apply once\n'
4283
- elif [ -f "$EXAMPLE" ]; then
4284
- # The row stays until apply installs the component and writes a new stamp.
4285
- # Only the detail moves: once upgrade has appended the block, "not in your
4286
- # config" is false, and a report that says it is stops being worth reading.
4287
- ACSTACK_STAMP="$STAMP" py "$EXAMPLE" "$CFG" <<'PY'
4288
- import os, re, sys
4289
- import yaml
4290
- had = {l.strip() for l in open(os.environ["ACSTACK_STAMP"])
4291
- if l.strip() and not l.startswith("#")}
4292
- mine = open(sys.argv[2]).read() if os.path.isfile(sys.argv[2]) else ""
4293
- for k in (yaml.safe_load(open(sys.argv[1])) or {}):
4294
- if k in had:
4295
- continue
4296
- if re.search(r"(?m)^%s:" % re.escape(k), mine):
4297
- print("new\t%s\tnew component, run apply to install it" % k)
4298
- else:
4299
- print("new\t%s\tnew component, not in your config" % k)
4300
- PY
4301
- fi
4302
-
4303
- while IFS=$'\t' read -r name key src ref _; do
4304
- [ "$(cfg_get "$key.enabled" "$(enabled_default "$key")")" = "true" ] || continue
4305
- # A fork build's version comes from a local checkout, so a release tag says
4306
- # nothing about it.
4307
- [ "$name" = "tuicr" ] && [ "$(cfg_get tuicr.build_from_fork false)" = "true" ] && continue
4308
- # pi update never moves a pinned npm source, so a newer release is nothing
4309
- # upgrade could fetch. Say so once instead of reporting it stale forever.
4310
- if pi_npm_pin "$name"; then
4311
- printf 'unknown\t%s\tpinned to %s in pi, which pi update never moves\n' "$name" "$(pi_state pkg "npm:$name")"
4312
- continue
4313
- fi
4314
- inst=$(installed_version "$name")
4315
- [ -n "$inst" ] || continue # not installed is apply's business, not this one
4316
- latest=$(latest_version "$src" "$ref")
4317
- if [ -z "$latest" ]; then
4318
- [ "${ACSTACK_TW_OFFLINE:-0}" = "1" ] \
4319
- || printf 'unknown\t%s\tcould not reach %s\n' "$name" "$src"
4320
- elif [ "$inst" != "$latest" ]; then
4321
- printf 'stale\t%s\t%s -> %s\n' "$name" "$inst" "$latest"
4322
- fi
4323
- done < <(version_table)
4324
-
4325
- # The ACStack package on pi is pinned to a release, so it falls behind the
4326
- # moment this plugin updates. Both versions are local, so this needs no
4327
- # network and reports even offline.
4328
- if [ "$(cfg_get pi.enabled false)" = "true" ]; then
4329
- local behind; behind=$(pi_acstack_behind)
4330
- [ -n "$behind" ] && printf 'stale\tpi-acstack\t%s -> %s\n' "${behind%%$'\t'*}" "${behind#*$'\t'}"
4331
- fi
4332
- }
4333
-
4334
- # Reinstalls over what is there, from the coordinates the table already holds.
4335
- # Split out so the tuicr arm can guard the fork build and still reach it,
4336
- # rather than carrying a second copy of the repository and the asset match.
4337
- upgrade_from_table() { # upgrade_from_table <name>
4338
- local repo match
4339
- read -r repo match < <(version_table | awk -F'\t' -v n="$1" '$1==n{print $4, $5}')
4340
- [ -n "${repo:-}" ] && [ "$repo" != "-" ] \
4341
- || { say "$1 is not a tool this script installs"; return 1; }
4342
- INSTALL_FORCE=1 install_binary "$1" "$repo" "$match"
4343
- # yazi's archive carries ya beside it, and the two must not drift.
4344
- if [ "$1" = "yazi" ]; then
4345
- INSTALL_FORCE=1 install_binary ya sxyazi/yazi "$match"
4346
- fi
4347
- }
4348
-
4349
- upgrade_one() { # upgrade_one <name>
4350
- if [ "$1" = "pi-acstack" ] \
4351
- || version_table | awk -F'\t' -v n="$1" '$1==n && $2=="pi" && $3=="npm"{found=1} END{exit !found}'; then
4352
- find_pi
4353
- fi
4354
- case "$1" in
4355
- herdr) herdr update >/dev/null 2>&1 \
4356
- && say "herdr updated" || say "herdr update failed" ;;
4357
- gh-dash) gh extension upgrade dlvhdr/gh-dash >/dev/null 2>&1 \
4358
- && say "gh-dash upgraded" || say "gh-dash upgrade failed" ;;
4359
- harlequin) uv tool upgrade harlequin >/dev/null 2>&1 \
4360
- && say "harlequin upgraded" || say "harlequin upgrade failed" ;;
4361
- # With no target, pi update updates pi itself and no package.
4362
- pi) if pi update >/dev/null 2>&1; then
4363
- say "pi updated"
4364
- patch_pi_win32_paste
4365
- else
4366
- say "pi update failed"
4367
- fi ;;
4368
- pi-subagents|pi-compact-tools|pi-zentui|pi-web-access|pi-lens|pi-slash-anywhere|@juicesharp/rpiv-ask-user-question|billion-context|@juicesharp/rpiv-todo|pi-plugin-manager)
4369
- if pi_npm_pin "$1"; then
4370
- say "$1 is pinned to $(pi_state pkg "npm:$1") in pi, which pi update never moves"
4371
- return 0
4372
- fi
4373
- pi update "npm:$1" >/dev/null 2>&1 \
4374
- && say "$1 updated" || say "$1 update failed" ;;
4375
- # Only a pin behind this plugin. A newer pin is the user's choice, and an
4376
- # absent or unpinned ACStack is apply's business.
4377
- pi-acstack)
4378
- [ -n "$(pi_acstack_behind)" ] || { say "pi-acstack: nothing behind this plugin to move"; return 0; }
4379
- pi_add "$PI_ACSTACK@$(acstack_version)" "$PI_ACSTACK" ;;
4380
- # Absent from the table, because it has no version flag for outdated to
4381
- # read. Naming it is the only way to refresh it, so the arm it needs is
4382
- # here rather than behind a table lookup that will never find it.
4383
- mermaid-ascii)
4384
- INSTALL_FORCE=1 install_binary mermaid-ascii AlexanderGrooff/mermaid-ascii Linux_x86_64 ;;
4385
- hadolint)
4386
- INSTALL_FORCE=1 install_plain_github_binary hadolint hadolint/hadolint Linux-x86_64 ;;
4387
- terragrunt)
4388
- INSTALL_FORCE=1 install_plain_github_binary terragrunt gruntwork-io/terragrunt linux_amd64 ;;
4389
- # The fork build comes from a local checkout, so a release archive would
4390
- # replace it with the stock binary and silently drop the patches it carries.
4391
- # outdated already skips it; naming it here has to skip it too.
4392
- tuicr)
4393
- if [ "$(cfg_get tuicr.build_from_fork false)" = "true" ]; then
4394
- say "tuicr is built from a fork here, so run apply to rebuild it"
4395
- return 0
4396
- fi
4397
- upgrade_from_table tuicr ;;
4398
- *) upgrade_from_table "$1" ;;
4399
- esac
4400
- }
4401
-
4402
- case "$ACTION" in
4403
- status)
4404
- echo "config: $CFG $([ -f "$CFG" ] && echo '(found)' || echo '(missing, using defaults)')"
4405
- find_pi
4406
- for t in herdr delta diffnav fzf lazygit tuicr yazi glow mermaid-ascii harlequin gh nvim pi; do
4407
- case "$t" in
4408
- gh) k=gh_dash ;; glow|mermaid-ascii) k=markdown ;; fzf) k=diffnav ;;
4409
- nvim) k=neovim ;;
4410
- *) k="$t" ;;
4411
- esac
4412
- # neovim and pi default to off, so reading them with the default every
4413
- # other one takes would report them enabled when the config says nothing.
4414
- printf ' %-13s %-14s enabled=%s\n' "$t" "$(have "$t" && echo installed || echo 'not installed')" \
4415
- "$(cfg_get "$k.enabled" "$(enabled_default "$k")")"
4416
- done
4417
- grep -q "$BEGIN" "$HERDR_CFG" 2>/dev/null && say "herdr managed block: present" || say "herdr managed block: absent"
4418
- echo
4419
- # Where a copy and an open actually land, which is the first thing to
4420
- # check when o or y misbehaves on a machine reached over SSH.
4421
- if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then
4422
- say "clipboard: this machine has a display, using its own"
4423
- elif [ -x "$BIN/xclip" ]; then
4424
- say "clipboard: xclip stand-in, copying to your terminal over OSC 52"
4425
- else
4426
- say "clipboard: none - run apply, or copy will fail in gh-dash and friends"
4427
- fi
4428
- if grep -q 'vim\.g\.clipboard' "$NVIM_PLUGIN" 2>/dev/null; then
4429
- say "neovim clipboard: OSC 52, written by this setup"
4430
- elif mine="$(nvim_user_sets 'g\.clipboard')"; then
4431
- say "neovim clipboard: set by $mine"
4432
- elif [ "$(cfg_get neovim.enabled false)" = "true" ]; then
4433
- say "neovim clipboard: none - run apply, or :%y+ fails over SSH"
4434
- fi
4435
- # Ask the opener itself rather than repeating its rules here. Two copies
4436
- # of this decision drifting apart is exactly how o came to claim it had
4437
- # opened a page it had not.
4438
- case "$(acstack-open --why 2>/dev/null)" in
4439
- command) say "browser: \$ACSTACK_OPEN_CMD = $ACSTACK_OPEN_CMD" ;;
4440
- xdg-open) say "browser: xdg-open on this machine" ;;
4441
- wslview|wsl-open|explorer.exe)
4442
- say "browser: the Windows side of this machine" ;;
4443
- opener) say "browser: your default browser on your laptop, through the opener" ;;
4444
- bridge) say "browser: Chrome on your laptop, through the Playwriter bridge" ;;
4445
- link) say "browser: none - o hands you a ctrl+clickable link and copies it" ;;
4446
- clipboard) say "browser: none, and no terminal either - o copies and reports failure" ;;
4447
- *) say "browser: unknown - run apply" ;;
4448
- esac
4449
- [ -f "$HOME/.cache/acstack/open.log" ] \
4450
- && say "last open: $(tail -1 "$HOME/.cache/acstack/open.log")"
4451
- # What the pi component set up, read from pi's own files. Shown whenever
4452
- # pi is here or asked for, because a pi installed by hand is still one the
4453
- # user may want to know about.
4454
- if have pi || [ "$(cfg_get pi.enabled false)" = "true" ]; then
4455
- echo
4456
- have pi && say "pi: $(installed_version pi)" || say "pi: not installed"
4457
- for id in npm:pi-subagents "$PI_ACSTACK" "$PI_BILLION" npm:@juicesharp/rpiv-todo npm:pi-plugin-manager npm:@juicesharp/rpiv-ask-user-question npm:pi-web-access npm:pi-lens "$PI_SLASH_ANYWHERE" npm:pi-compact-tools "$PI_ZENTUI"; do
4458
- src=$(pi_state pkg "$id") && say "pi package: $src" || say "pi package: $id not installed"
4459
- done
4460
- [ -e "$PI_RETRO_EXT" ] && say "pi agentic-retro extension: present" \
4461
- || say "pi agentic-retro extension: absent"
4462
- grep -qsF "$PI_MD_BEGIN" "$PI_DIR"/AGENTS.override.md "$PI_DIR"/AGENTS.md "$PI_DIR"/AGENTS.MD \
4463
- "$PI_DIR"/CLAUDE.md "$PI_DIR"/CLAUDE.MD \
4464
- && say "pi delegation line: present" || say "pi delegation line: absent"
4465
- ta=$(json_field "$PI_SUBAGENT_CFG" toolActivation)
4466
- say "pi toolActivation: ${ta:-not set}"
4467
- [ -f "$PI_DIR/auth.json" ] && say "pi login: logged in" \
4468
- || say "pi login: not logged in - run pi, then /login"
4469
- fi
4470
- if [ "$(cfg_get tuicr.build_from_fork false)" = "true" ]; then
4471
- echo
4472
- say "tuicr: local fork build - this is meant to be temporary"
4473
- if have gh; then
4474
- for pr in 607 633; do
4475
- state=$(gh pr view "$pr" --repo agavra/tuicr --json state -q .state 2>/dev/null || echo "unknown")
4476
- printf ' agavra/tuicr#%s %s\n' "$pr" "$state"
4477
- done
4478
- say "both MERGED? set tuicr.build_from_fork: false and re-run apply"
4479
- say "check the merged key names first - review can rename them"
4480
- else
4481
- say "install gh to have this check agavra/tuicr#607 and #633 for you"
4482
- fi
4483
- fi
4484
- ;;
4485
- apply)
4486
- require_yaml
4487
- arch_supported || exit 1
4488
- # Every setting has a default, so a missing config is a working setup and
4489
- # not an error. Say so anyway: the alternative is a run that looks like it
4490
- # read your settings and did not.
4491
- [ -f "$CFG" ] || say "no config at $CFG, so every setting takes its default"
4492
- [ "$(cfg_get enabled true)" = "true" ] || { echo "terminal workspace disabled in config"; exit 0; }
4493
- if [ "$(cfg_get herdr.enabled true)" = "true" ] && ! have herdr; then
4494
- # herdr ships an installer that picks the right build and verifies a
4495
- # checksum. Prefer it over matching release asset names ourselves.
4496
- curl -fsSL https://herdr.dev/install.sh | sh >/dev/null 2>&1 \
4497
- && say "herdr installed" || say "herdr install failed, see herdr.dev"
4498
- fi
4499
- [ "$(cfg_get delta.enabled true)" = "true" ] && install_binary delta dandavison/delta x86_64-unknown-linux-gnu
4500
- [ "$(cfg_get diffnav.enabled true)" = "true" ] && install_binary diffnav dlvhdr/diffnav Linux_x86_64
4501
- # fzf drives the comparison picker behind acstack-diff pick.
4502
- [ "$(cfg_get diffnav.enabled true)" = "true" ] && install_binary fzf junegunn/fzf linux_amd64
4503
- [ "$(cfg_get lazygit.enabled true)" = "true" ] && install_binary lazygit jesseduffield/lazygit linux_x86_64
4504
- write_helpers
4505
- [ "$(cfg_get tuicr.enabled true)" = "true" ] && apply_tuicr
4506
- apply_yazi
4507
- apply_markdown
4508
- apply_neovim
4509
- [ "$(cfg_get herdr.enabled true)" = "true" ] && apply_herdr
4510
- [ "$(cfg_get herdr.enabled true)" = "true" ] && apply_herdr_numbers
4511
- [ "$(cfg_get delta.enabled true)" = "true" ] && apply_delta
4512
- [ "$(cfg_get remote.enabled true)" = "true" ] && apply_remote
4513
- [ "$(cfg_get gh_dash.enabled true)" = "true" ] && apply_ghdash
4514
- [ "$(cfg_get harlequin.enabled true)" = "true" ] && apply_harlequin
4515
- [ "$(cfg_get pi.enabled false)" = "true" ] && apply_pi
4516
- echo "done. open a herdr session and press prefix+? to see the keymap"
4517
- # Last, so a run that died half way leaves no stamp claiming success.
4518
- write_stamp
4519
- ;;
4520
- outdated)
4521
- # Exit 2 means "cannot tell", and a missing PyYAML is exactly that.
4522
- # require_yaml exits 1, which every caller reads as "there is something to
4523
- # fetch" and acts on findings this run was never able to produce.
4524
- python3 -c 'import yaml' 2>/dev/null || {
4525
- echo "PyYAML is required to read $CFG, so nothing here can be compared" >&2
4526
- exit 2; }
4527
- [ -f "$EXAMPLE" ] || { echo "no config.example.yaml beside $0" >&2; exit 2; }
4528
- out=$(outdated_report)
4529
- [ -n "$out" ] || exit 0
4530
- printf '%s\n' "$out"
4531
- # An unknown is not a finding: nothing can be done about a tool with no
4532
- # version flag or a GitHub that did not answer. Only a real one exits 1.
4533
- printf '%s\n' "$out" | grep -qE '^(missing|new|stale)' && exit 1
4534
- exit 0
4535
- ;;
4536
- upgrade)
4537
- require_yaml
4538
- arch_supported || exit 1
4539
- shift
4540
- names="$*"
4541
- report=""
4542
- if [ -z "$names" ]; then
4543
- report=$(outdated_report)
4544
- names=$(printf '%s\n' "$report" | awk -F'\t' '$1=="stale"{print $2}')
4545
- fi
4546
- for n in $names; do upgrade_one "$n"; done
4547
-
4548
- # A new component is a config edit, not a download. Append the block the
4549
- # example ships, comments and all, rather than rewriting the file through
4550
- # yaml - a round trip through the parser drops every comment the user has.
4551
- printf '%s\n' "$report" | awk -F'\t' '$1=="new"{print $2}' | while read -r comp; do
4552
- [ -n "$comp" ] || continue
4553
- ACSTACK_COMP="$comp" py "$EXAMPLE" "$CFG" <<'PY'
4554
- import os, re, sys
4555
- comp = os.environ["ACSTACK_COMP"]
4556
- ship = open(sys.argv[1]).read()
4557
- # The block runs from the component's own key to the next top-level key. A
4558
- # top-level key is the only thing that starts in column zero and ends in a
4559
- # colon, which is what makes a plain text slice safe here.
4560
- m = re.search(r"(?ms)^%s:.*?(?=^\S+:|\Z)" % re.escape(comp), ship)
4561
- if not m:
4562
- raise SystemExit
4563
- block = m.group(0).rstrip() + "\n"
4564
- mine = open(sys.argv[2]).read() if os.path.isfile(sys.argv[2]) else ""
4565
- if re.search(r"(?m)^%s:" % re.escape(comp), mine):
4566
- raise SystemExit
4567
- open(sys.argv[2], "a").write(("" if mine.endswith("\n\n") else "\n") + block)
4568
- print(" %s added to your config, with its comments" % comp)
4569
- PY
4570
- done
4571
-
4572
- say "run apply to install anything new"
4573
- ;;
4574
- disable)
4575
- DISABLE_USAGE="usage: setup.sh disable <herdr|delta|diffnav|gh_dash|harlequin|lazygit|neovim|pi|tuicr>"
4576
- comp="${2:?$DISABLE_USAGE}"
4577
- case "$comp" in
4578
- delta) for k in core.pager interactive.diffFilter delta.navigate delta.line-numbers; do
4579
- git config --global --unset "$k" 2>/dev/null
4580
- done
4581
- say "delta unset as git pager"
4582
- say "now set delta.enabled: false in $CFG so apply does not restore it" ;;
4583
- herdr)
4584
- py "$HERDR_CFG" "$BEGIN" "$END" <<'PY'
4585
- import re, sys, os
4586
- p, b, e = sys.argv[1:4]
4587
- if os.path.isfile(p):
4588
- t = open(p).read()
4589
- open(p, "w").write(re.sub(re.escape(b) + r".*?" + re.escape(e) + r"\n?", "", t, flags=re.S))
4590
- PY
4591
- say "managed block removed from herdr config"
4592
- say "now set herdr.enabled: false in $CFG so apply does not restore it" ;;
4593
- neovim)
4594
- # A whole file of ours, so turning it off is a deletion. Anything the
4595
- # user wrote by hand under that name is theirs and stays.
4596
- if nvim_ours; then
4597
- rm -f "$NVIM_PLUGIN"
4598
- say "neovim plugin file removed"
4599
- fi
4600
- say "now set neovim.enabled: false in $CFG so apply does not restore it" ;;
4601
- pi)
4602
- # Only what apply added. The pi binary, its logins and its sessions
4603
- # stay, and so does anything the user installed into pi themselves.
4604
- remove_pi
4605
- say "now set pi.enabled: false in $CFG so apply does not restore it" ;;
4606
- diffnav|gh_dash|harlequin|lazygit|tuicr)
4607
- # Only this component's popup goes away. Rebuild the managed block so
4608
- # the rest of the keymap survives.
4609
- require_yaml
4610
- ACSTACK_CFG="$CFG" py "$comp" <<'PY'
4611
- import os, sys, yaml
4612
- p = os.environ["ACSTACK_CFG"]
4613
- c = yaml.safe_load(open(p)) or {}
4614
- c.setdefault(sys.argv[1], {})["enabled"] = False
4615
- yaml.safe_dump(c, open(p, "w"), sort_keys=False)
4616
- PY
4617
- apply_herdr
4618
- say "$comp disabled, rest of the keymap left in place" ;;
4619
- yazi|markdown|remote)
4620
- # These three have no keys to remove and no git settings to unset, so
4621
- # there is nothing here to carry out. Refusing says so; the arm that
4622
- # was missing let the script exit 0 in silence, which reads as done.
4623
- say "$comp has no disable step"
4624
- say "set $comp.enabled: false in $CFG and run apply instead"
4625
- exit 1 ;;
4626
- *) echo "$comp is not a component. $DISABLE_USAGE" >&2; exit 1 ;;
4627
- esac
4628
- ;;
4629
- uninstall)
4630
- "$0" disable herdr; "$0" disable delta; "$0" disable neovim; "$0" disable pi
4631
- # By prefix, which is why helpers are named acstack-*: anything this
4632
- # script ever wrote goes, including helpers retired in an older version.
4633
- herdr plugin unlink acstack.herdr-numbers >/dev/null 2>&1 || true
4634
- rm -rf "$HOME/.config/acstack/herdr-numbers"
4635
- rm -f "$BIN"/acstack-*
4636
- # The xclip stand-in is the one managed file without the prefix.
4637
- grep -q "Installed by acstack" "$BIN/xclip" 2>/dev/null && rm -f "$BIN/xclip"
4638
- # Every file apply leaves a marked region in. Removing the region is the
4639
- # same edit `disable herdr` makes, so whatever the user wrote around it
4640
- # comes through untouched. Left behind, the yazi regions keep routing
4641
- # markdown at acstack-md, a helper this command has just deleted.
4642
- ACSTACK_ARGS="$BEGIN|$END" py \
4643
- "$HOME/.config/tuicr/config.toml" \
4644
- "$HOME/.config/yazi/yazi.toml" \
4645
- "$HOME/.config/yazi/keymap.toml" <<'PY'
4646
- import os, re, sys
4647
- b, e = os.environ["ACSTACK_ARGS"].split("|")
4648
- for p in sys.argv[1:]:
4649
- if os.path.isfile(p):
4650
- t = open(p).read()
4651
- open(p, "w").write(re.sub(re.escape(b) + r".*?" + re.escape(e) + r"\n?", "", t, flags=re.S))
4652
- PY
4653
- # gh-dash reads YAML that python rewrites whole, so a marker comment never
4654
- # survives the round trip. What identifies acstack's work there is the name
4655
- # on each keybinding, the same handle apply_ghdash uses to replace its own
4656
- # entries, plus the two page keys it binds. Sections, repo paths and every
4657
- # other keybinding belong to the user and stay.
4658
- if [ -f "$GHDASH_CFG" ] && python3 -c 'import yaml' 2>/dev/null; then
4659
- py "$GHDASH_CFG" <<'PY'
4660
- import sys, yaml
4661
- p = sys.argv[1]
4662
- cfg = yaml.safe_load(open(p)) or {}
4663
- MANAGED = ("review (tuicr)", "tree diff", "agent review", "open in browser")
4664
- kbs = cfg.get("keybindings") or {}
4665
- for scope in ("prs", "issues"):
4666
- if scope in kbs:
4667
- kbs[scope] = [k for k in kbs[scope] or [] if k.get("name") not in MANAGED]
4668
- if not kbs[scope]:
4669
- del kbs[scope]
4670
- if "universal" in kbs:
4671
- kbs["universal"] = [k for k in kbs["universal"] or []
4672
- if k.get("builtin") not in ("pageUp", "pageDown")]
4673
- if not kbs["universal"]:
4674
- del kbs["universal"]
4675
- if kbs:
4676
- cfg["keybindings"] = kbs
4677
- else:
4678
- cfg.pop("keybindings", None)
4679
- yaml.safe_dump(cfg, open(p, "w"), sort_keys=False, default_flow_style=False, width=200)
4680
- PY
4681
- say "gh-dash keybindings removed"
4682
- fi
4683
- # The one tool uv owns rather than this script. Leaving it behind leaves an
4684
- # environment nothing on the machine points at any more.
4685
- if have uv && uv tool list 2>/dev/null | grep -q '^harlequin '; then
4686
- uv tool uninstall harlequin >/dev/null 2>&1 && say "harlequin removed"
4687
- fi
4688
- say "managed config and helpers removed. binaries left in place, except"
4689
- say "harlequin, which uv owns whole rather than as a file in $BIN"
4690
- say "pi stays too, with its logins and sessions: npm uninstall -g @earendil-works/pi-coding-agent"
4691
- ;;
4692
- *) echo "usage: setup.sh [status|apply|outdated|upgrade|disable <component>|uninstall]"; exit 1 ;;
4693
- esac