acstack 0.0.0-stage → 0.47.0

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