acstack 0.49.0 → 0.49.2

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 (36) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/assets/terminal-workspace/helpers/acstack-bridge +185 -0
  3. package/assets/terminal-workspace/helpers/acstack-clip +88 -0
  4. package/assets/terminal-workspace/helpers/acstack-dash +25 -0
  5. package/assets/terminal-workspace/helpers/acstack-db +86 -0
  6. package/assets/terminal-workspace/helpers/acstack-diff +268 -0
  7. package/assets/terminal-workspace/helpers/acstack-edit +25 -0
  8. package/assets/terminal-workspace/helpers/acstack-gh +47 -0
  9. package/assets/terminal-workspace/helpers/acstack-herdr-renumber +93 -0
  10. package/assets/terminal-workspace/helpers/acstack-herdr-tab +49 -0
  11. package/assets/terminal-workspace/helpers/acstack-md +401 -0
  12. package/assets/terminal-workspace/helpers/acstack-md-lib +219 -0
  13. package/assets/terminal-workspace/helpers/acstack-open +217 -0
  14. package/assets/terminal-workspace/helpers/acstack-popup +13 -0
  15. package/assets/terminal-workspace/helpers/acstack-view +33 -0
  16. package/assets/terminal-workspace/helpers/xclip +31 -0
  17. package/assets/terminal-workspace/lib/apply-ghdash.sh +78 -0
  18. package/assets/terminal-workspace/lib/apply-herdr-numbers.sh +49 -0
  19. package/assets/terminal-workspace/lib/apply-herdr.sh +308 -0
  20. package/assets/terminal-workspace/lib/apply-tools.sh +293 -0
  21. package/assets/terminal-workspace/lib/apply-tuicr.sh +133 -0
  22. package/assets/terminal-workspace/lib/apply-yazi.sh +389 -0
  23. package/assets/terminal-workspace/lib/commands.sh +303 -0
  24. package/assets/terminal-workspace/lib/core.sh +184 -0
  25. package/assets/terminal-workspace/lib/helper-install.sh +35 -0
  26. package/assets/terminal-workspace/lib/pi-packages.sh +360 -0
  27. package/assets/terminal-workspace/lib/pi-state.sh +479 -0
  28. package/assets/terminal-workspace/lib/versions.sh +254 -0
  29. package/assets/terminal-workspace/pi/acstack-question-blocked.ts +31 -0
  30. package/assets/terminal-workspace/setup.sh +25 -4637
  31. package/cli/package.json +1 -1
  32. package/docs/terminal-workspace.md +10 -1
  33. package/package.json +1 -1
  34. package/skills/handoff/SKILL.md +14 -14
  35. package/skills/handoff/launch-herdr.md +15 -4
  36. package/skills/implement/SKILL.md +5 -0
@@ -0,0 +1,25 @@
1
+ #!/usr/bin/env bash
2
+ # Open one file in its own herdr tab, for tools that shell out to $EDITOR.
3
+ #
4
+ # acstack-edit <file>
5
+ #
6
+ # diffnav's "o" key runs $EDITOR inside diffnav's own pane. For alt+f and alt+x
7
+ # that pane is a herdr popup, and a popup cannot host a second full-screen app,
8
+ # so the editor never draws and the key looks dead. This gives the file its own
9
+ # herdr tab and returns at once, which leaves the diff untouched behind the
10
+ # popup. Close the popup to reach the tab.
11
+ #
12
+ # ACSTACK_EDIT_EDITOR names the real editor: whatever set this script as
13
+ # $EDITOR has already taken that variable.
14
+ #
15
+ # acstack-herdr-tab does the work. It finds the pane behind the popup, creates
16
+ # the tab, and runs the editor in it, and it runs the editor in place when
17
+ # there is no herdr to ask. Installed by acstack.
18
+ set -uo pipefail
19
+
20
+ ED="${ACSTACK_EDIT_EDITOR:-nvim}"
21
+ case "$ED" in *acstack-edit) ED=nvim ;; esac # never call itself
22
+ FILE="${1:?usage: acstack-edit <file>}"
23
+
24
+ command -v acstack-herdr-tab >/dev/null 2>&1 || exec "$ED" "$FILE"
25
+ exec acstack-herdr-tab "$(basename "$FILE")" "$ED" "$FILE"
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env bash
2
+ # gh, plus the reason a pull request action failed.
3
+ #
4
+ # gh-dash runs gh for everything it does to a pull request - approve, comment,
5
+ # merge - and throws the command's stderr away, so GitHub refusing one shows as
6
+ # "exit status 1" in the footer for two seconds and nothing else. Approving your
7
+ # own pull request is the everyday case: GitHub always refuses that, and the
8
+ # dashboard looks like it simply ignored the keypress.
9
+ #
10
+ # acstack-dash links this into a directory of its own at the front of $PATH
11
+ # under the name gh, so gh-dash finds it first. Everything is handed to the real
12
+ # gh untouched and its exit code is returned as-is; the only thing added is a
13
+ # notification when an action fails, because a suspended TUI cannot print for
14
+ # us. Installed by acstack.
15
+ set -uo pipefail
16
+
17
+ # The real gh is the first one on $PATH that is not this script under another
18
+ # name. Comparing what each resolves to sees through both the link acstack-dash
19
+ # makes and a plain copy of this file.
20
+ ME="$(readlink -f "$0")"
21
+ REAL=""
22
+ while IFS= read -r dir; do
23
+ [ -n "$dir" ] && [ -x "$dir/gh" ] || continue
24
+ [ "$(readlink -f "$dir/gh")" = "$ME" ] && continue
25
+ REAL="$dir/gh"; break
26
+ done < <(printf '%s\n' "${PATH//:/$'\n'}")
27
+ [ -n "$REAL" ] || { echo "acstack-gh: no gh on \$PATH besides this shim" >&2; exit 127; }
28
+
29
+ # The dashboard is not an action: it owns the terminal for as long as it runs.
30
+ [ "${1:-}" = "dash" ] && exec "$REAL" "$@"
31
+
32
+ ERR="$(mktemp)"; trap 'rm -f "$ERR"' EXIT
33
+ "$REAL" "$@" 2>"$ERR"
34
+ RC=$?
35
+ cat "$ERR" >&2
36
+
37
+ # Only what a dashboard key fires. gh's own plumbing - repo view, api, auth -
38
+ # fails for reasons a notification cannot help with, and acstack-dash asks gh
39
+ # which repository this is before the dashboard has even started.
40
+ case "${1:-}" in pr|issue) ;; *) exit $RC ;; esac
41
+ [ "$RC" -eq 0 ] && exit 0
42
+
43
+ MSG="$(grep -v '^[[:space:]]*$' "$ERR" | head -3)"
44
+ [ -n "$MSG" ] || MSG="gh exited with status $RC"
45
+ command -v herdr >/dev/null 2>&1 \
46
+ && herdr notification show "gh ${1:-} ${2:-} failed" --body "$MSG" >/dev/null 2>&1
47
+ exit $RC
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env python3
2
+ """Make the herdr sidebar numbers match prefix+1..9.
3
+
4
+ herdr draws a workspace's position in its stored list, but prefix+N targets the
5
+ row's position in the grouped sidebar, where worktrees sit indented under their
6
+ parent repo. The two agree until you create or tear down a worktree: new ones
7
+ append to the end of the list but appear mid-sidebar under their parent, so the
8
+ numbers you read stop being the numbers you press.
9
+
10
+ This rewrites the stored order to match the grouped order, so both agree again.
11
+ Run it after adding or removing a worktree. Safe to run repeatedly. Installed
12
+ by acstack.
13
+ """
14
+ import json
15
+ import os
16
+ import socket
17
+ import sys
18
+
19
+
20
+ def socket_path():
21
+ override = os.environ.get("HERDR_SOCKET_PATH")
22
+ if override:
23
+ return override
24
+ config = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
25
+ return os.path.join(config, "herdr", "herdr.sock")
26
+
27
+
28
+ def call(method, params=None):
29
+ try:
30
+ conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
31
+ conn.connect(socket_path())
32
+ except OSError as err:
33
+ sys.exit(f"acstack-herdr-renumber: no herdr server at {socket_path()} ({err})")
34
+ with conn:
35
+ conn.sendall((json.dumps({"id": "renumber", "method": method,
36
+ "params": params or {}}) + "\n").encode())
37
+ buf = b""
38
+ while not buf.endswith(b"\n"):
39
+ chunk = conn.recv(65536)
40
+ if not chunk:
41
+ break
42
+ buf += chunk
43
+ lines = buf.decode().strip().splitlines()
44
+ if not lines:
45
+ sys.exit(f"acstack-herdr-renumber: herdr closed the connection during {method}")
46
+ reply = json.loads(lines[0])
47
+ if "error" in reply:
48
+ sys.exit(f"acstack-herdr-renumber: {method} failed: {reply['error']}")
49
+ return reply
50
+
51
+
52
+ def workspaces():
53
+ return call("workspace.list")["result"]["workspaces"]
54
+
55
+
56
+ def grouped_order(spaces):
57
+ """The order the sidebar draws: every repo's rows contiguous, the repo
58
+ checkout above the worktrees indented under it, stored order within each
59
+ of those two parts. A group sits where its repo checkout sits, not where
60
+ its first row happens to sit, so a worktree cannot drag its parent up."""
61
+ groups, anchors = {}, {}
62
+ for position, space in enumerate(spaces):
63
+ tree = space.get("worktree") or {}
64
+ root = tree.get("repo_root") or space["workspace_id"]
65
+ groups.setdefault(root, []).append((bool(tree.get("is_linked_worktree")),
66
+ position, space["workspace_id"]))
67
+ if not tree.get("is_linked_worktree") and root not in anchors:
68
+ anchors[root] = position
69
+ # A group of nothing but worktrees, its repo checkout never opened, anchors
70
+ # on its first row instead.
71
+ for root, members in groups.items():
72
+ anchors.setdefault(root, min(position for _, position, _ in members))
73
+ order = []
74
+ for root in sorted(groups, key=lambda key: anchors[key]):
75
+ order.extend(wid for _, _, wid in sorted(groups[root]))
76
+ return order
77
+
78
+
79
+ def main():
80
+ before = workspaces()
81
+ target = grouped_order(before)
82
+ if [space["workspace_id"] for space in before] == target:
83
+ print("already aligned")
84
+ else:
85
+ for index, workspace_id in enumerate(target):
86
+ call("workspace.move", {"workspace_id": workspace_id, "insert_index": index})
87
+ for space in workspaces():
88
+ indent = " " if (space.get("worktree") or {}).get("is_linked_worktree") else ""
89
+ print(f"{space['number']:>3} {indent}{space['label']}")
90
+
91
+
92
+ if __name__ == "__main__":
93
+ main()
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env bash
2
+ # Run a command in a new herdr tab, beside the pane the key was pressed in.
3
+ #
4
+ # acstack-herdr-tab <label> <command> [args...]
5
+ #
6
+ # herdr has no type = "tab" custom command, and its tab.create API launches a
7
+ # shell rather than a command, so the tab is created first and the command
8
+ # sent into it with `herdr pane run`.
9
+ #
10
+ # The target comes from `herdr pane current`, not from HERDR_* variables: a
11
+ # type = "shell" binding runs detached with none of them set, and reading the
12
+ # environment sent every one of these commands off to run with no terminal.
13
+ # Asked without that environment, herdr reports the focused pane, which is
14
+ # exactly the one the key was pressed in. Installed by acstack.
15
+ set -uo pipefail
16
+
17
+ label="${1:?usage: acstack-herdr-tab <label> <command> [args...]}"; shift
18
+ [ $# -gt 0 ] || { echo "acstack-herdr-tab: no command given" >&2; exit 1; }
19
+
20
+ command -v herdr >/dev/null 2>&1 || exec "$@"
21
+
22
+ cur=$(herdr pane current 2>/dev/null) || exec "$@"
23
+ read -r ws cwd <<<"$(printf '%s' "$cur" | python3 -c '
24
+ import json, sys
25
+ p = json.load(sys.stdin).get("result", {}).get("pane", {})
26
+ print(p.get("workspace_id") or "", p.get("foreground_cwd") or p.get("cwd") or "")
27
+ ' 2>/dev/null)"
28
+ [ -n "${ws:-}" ] || exec "$@"
29
+
30
+ resp=$(herdr tab create --workspace "$ws" ${cwd:+--cwd "$cwd"} --label "$label" --focus 2>/dev/null) \
31
+ || exec "$@"
32
+ pane=$(printf '%s' "$resp" | python3 -c \
33
+ 'import json,sys; print(json.load(sys.stdin)["result"]["root_pane"]["pane_id"])' 2>/dev/null)
34
+ [ -n "$pane" ] || exec "$@"
35
+
36
+ # herdr hosts a type = "shell" command in a real pane for as long as the
37
+ # process lives, so anything slow here shows up as a stray terminal in the
38
+ # current tab. Hand the wait to a detached child and return immediately: the
39
+ # host pane then lasts only as long as the tab.create call.
40
+ #
41
+ # The settle is because the new tab's shell may not have drawn its prompt yet.
42
+ # The pty buffers input, so it is belt and braces, not a correctness need.
43
+ #
44
+ # `herdr pane run` hands the string to a shell, so each argument is quoted on
45
+ # the way in. A bare "$*" loses any file path holding a space.
46
+ printf -v cmd '%q ' "$@"
47
+ ( sleep 0.3; herdr pane run "$pane" "$cmd" >/dev/null 2>&1 ) </dev/null >/dev/null 2>&1 &
48
+ disown 2>/dev/null || true
49
+ exit 0
@@ -0,0 +1,401 @@
1
+ #!/usr/bin/env bash
2
+ # Read a markdown file in the terminal with its mermaid diagrams drawn as text.
3
+ #
4
+ # acstack-md FILE.md render to the terminal
5
+ # acstack-md --preview FILE.md render into a preview pane: width from
6
+ # $COLUMNS, straight to stdout, no pager
7
+ # acstack-md --numbered FILE.md the source instead, line numbers down the
8
+ # left, for quoting a line back in a review
9
+ # acstack-md --toggle-line-numbers flip which of the two --preview shows
10
+ # acstack-md --diagram N FILE.md one diagram alone, with horizontal scroll
11
+ # acstack-md --serve FILE.md serve it for a real browser, print the link
12
+ # acstack-md --html FILE.md print that same page as one HTML document
13
+ # acstack-md --browser FILE.md open it in the browser you are sitting at
14
+ #
15
+ # While reading, b opens the page in that browser and # switches between the
16
+ # rendered document and its source with line numbers. $ACSTACK_MD_BROWSER_KEY
17
+ # and $ACSTACK_MD_LINE_NUMBERS_KEY move them.
18
+ #
19
+ # Text, not images, is deliberate: herdr emits the kitty graphics protocol and
20
+ # no terminal reachable from Windows or Android renders that, and e-ink panels
21
+ # render text far better than bitmaps anyway. Installed by acstack.
22
+ set -uo pipefail
23
+
24
+ PORT="${ACSTACK_MD_PORT:-6419}"
25
+ # Named rather than inherited from an ambient $AGENT_BROWSER_CDP, for the
26
+ # reason acstack-open gives at its own bridge branch: leaning on the ambient
27
+ # one launches a headless Chrome nobody can see and calls it success.
28
+ BRIDGE="${ACSTACK_BRIDGE_URL:-http://127.0.0.1:19988}"
29
+ OPENER="${ACSTACK_OPENER_URL:-http://127.0.0.1:19989}"
30
+ # The opener refuses anything without this, so its absence is the same as the
31
+ # opener being down - which is exactly how the route probe treats it.
32
+ OPENER_TOKEN="${XDG_CONFIG_HOME:-$HOME/.config}/acstack/opener.token"
33
+ SERVE=0; ONLY=0; PREVIEW=0; HTML=0; BROWSER=0; NUMBERED=0; TOGGLE=0
34
+ usage() { echo "usage: acstack-md [--preview] [--numbered] [--toggle-line-numbers] [--diagram N] [--serve|--html|--browser] FILE.md" >&2; exit 1; }
35
+ # The preview pane and the key that flips it are two processes that never meet,
36
+ # so the choice between the rendered view and the source lives in a file both
37
+ # of them can find. One name, defined once, is what keeps them agreeing.
38
+ LINE_NUMBER_FLAG="${XDG_STATE_HOME:-$HOME/.local/state}/acstack/md-line-numbers"
39
+ # A loop rather than a fixed order, so --preview composes with --diagram and
40
+ # a caller can pass them either way round.
41
+ while [ $# -gt 0 ]; do
42
+ case "$1" in
43
+ --serve) SERVE=1; shift ;;
44
+ --html) HTML=1; shift ;;
45
+ --browser) BROWSER=1; shift ;;
46
+ --preview) PREVIEW=1; shift ;;
47
+ --numbered) NUMBERED=1; shift ;;
48
+ --toggle-line-numbers) TOGGLE=1; shift ;;
49
+ --diagram) ONLY="${2:-1}"; shift; [ $# -gt 0 ] && shift ;;
50
+ --) shift; break ;;
51
+ --*) usage ;;
52
+ *) break ;;
53
+ esac
54
+ done
55
+ # The toggle names no file: it changes what the next preview will draw and has
56
+ # nothing to draw itself. So it answers here, before the file check below.
57
+ if [ "$TOGGLE" = "1" ]; then
58
+ if [ -e "$LINE_NUMBER_FLAG" ]; then
59
+ rm -f "$LINE_NUMBER_FLAG"
60
+ echo "acstack-md: markdown previews render again"
61
+ else
62
+ # A machine that has never had a state directory is every machine the
63
+ # first time, so make it rather than fail on it.
64
+ mkdir -p "$(dirname "$LINE_NUMBER_FLAG")" && : > "$LINE_NUMBER_FLAG" \
65
+ || { echo "acstack-md: could not write $LINE_NUMBER_FLAG" >&2; exit 1; }
66
+ echo "acstack-md: markdown previews show source with line numbers"
67
+ fi
68
+ exit 0
69
+ fi
70
+ # --preview, --serve and --html are three answers to the same question - where
71
+ # does this end up - so at most one can be true. A preview pane is somewhere to
72
+ # read, not somewhere to run a server or hand a document out of.
73
+ if [ $((SERVE + PREVIEW + HTML + BROWSER)) -gt 1 ]; then
74
+ echo "acstack-md: --preview, --serve, --html and --browser do different things - pick one" >&2
75
+ exit 1
76
+ fi
77
+ # --numbered answers a different question - which of the two views - so it
78
+ # composes with --preview and with nothing else. The others all want the
79
+ # rendered document, and numbered source is not one.
80
+ if [ "$NUMBERED" = "1" ] && [ $((SERVE + HTML + BROWSER)) -gt 0 ]; then
81
+ echo "acstack-md: --numbered prints the source, so --serve, --html and --browser have nothing to render" >&2
82
+ exit 1
83
+ fi
84
+ # The flag is what the file manager's key flips, and the pane is the only
85
+ # place it applies: every other route was asked for a rendered document by
86
+ # name, and the opener menu lists the numbered read as its own entry.
87
+ #
88
+ # --diagram is the exception the pane itself holds. It names one drawing to
89
+ # show, which is a different question from which of the two views the pane is
90
+ # on, and a reader who left the flag set and then asked for a diagram still
91
+ # wants the diagram. So an explicit --diagram outranks the flag. An explicit
92
+ # --numbered still wins over it, for the same reason in the other direction.
93
+ if [ "$PREVIEW" = "1" ] && [ "$ONLY" = "0" ] && [ -e "$LINE_NUMBER_FLAG" ]; then NUMBERED=1; fi
94
+ # Node labels set a diagram's width, so padding cannot rescue a wide one.
95
+ # Tightening still buys roughly a third of the height back.
96
+ PAD="${ACSTACK_MD_PAD:--x 2 -y 2}"
97
+ # A caller that exports COLUMNS means it, so it wins outright. tput reads
98
+ # COLUMNS too, ahead of terminfo, but that is a side effect worth naming
99
+ # rather than resting on - it goes when TERM is unset and tput fails.
100
+ case "${COLUMNS:-}" in
101
+ ''|*[!0-9]*) COLS=$(tput cols 2>/dev/null || echo 80) ;;
102
+ *) COLS="$COLUMNS" ;;
103
+ esac
104
+ [ "$COLS" -gt 0 ] 2>/dev/null || COLS=80
105
+ FILE="${1:-}"
106
+ [ -n "$FILE" ] && [ -f "$FILE" ] || usage
107
+ # One place to clean up, so no exit path leaks a temp file.
108
+ trap "rm -f /tmp/acstack-md.$$.md /tmp/acstack-md-out.$$.md /tmp/acstack-md-art.$$.* /tmp/acstack-md-keys.$$" EXIT
109
+
110
+ # Wanting a document in a browser happens while reading it, so the key belongs
111
+ # in the pager rather than before or after one.
112
+ #
113
+ # less has no action that runs a fixed command, and its one shell escape
114
+ # expands % to the file it was handed - which here is the rendered temp copy,
115
+ # not the markdown someone asked for. So the key quits instead: lesskey's quit
116
+ # action takes the first character of its extra string as the exit status, and
117
+ # "A" is 65, a value less never returns of its own accord. Nothing has to be
118
+ # passed through the binding, because this script already knows the file.
119
+ BROWSE_STATUS=65
120
+ BROWSE_KEY="${ACSTACK_MD_BROWSER_KEY:-b}"
121
+ # The same trick a second time, for the other thing a reader wants mid-document.
122
+ # Wanting a line number happens while reading too - that is the moment you are
123
+ # about to quote one - and by then the file list with its own binding is not in
124
+ # front of you. So the key means the same thing in both places.
125
+ NUMBER_STATUS=66
126
+ NUMBER_KEY="${ACSTACK_MD_LINE_NUMBERS_KEY:-#}"
127
+ KEYS="/tmp/acstack-md-keys.$$"
128
+
129
+ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
130
+ . "$SCRIPT_DIR/acstack-md-lib"
131
+
132
+ if [ "$NUMBERED" = "1" ]; then
133
+ numbered "$FILE" > /tmp/acstack-md.$$.md
134
+ # -S chops rather than wraps, so the arrow keys pan across a long line
135
+ # instead of the gutter losing its column. emit sends a pane straight to
136
+ # stdout and keeps the browser key working in the pager, both unchanged.
137
+ emit /tmp/acstack-md.$$.md less -SR
138
+ exit 0
139
+ fi
140
+
141
+ # --browser names a destination rather than a rendering: work out where the
142
+ # browser actually is, then pick the delivery that reaches it. acstack-open
143
+ # already answers that question for URLs, so ask it rather than deciding the
144
+ # same thing twice and drifting from it later.
145
+ if [ "$BROWSER" = "1" ]; then
146
+ case "$(command -v acstack-open >/dev/null 2>&1 && acstack-open --why 2>/dev/null)" in
147
+ opener)
148
+ # The opener stores the document on the laptop and serves it to the
149
+ # browser there, so the page still works once this machine stops
150
+ # answering - and a re-render of the same file updates the tab already
151
+ # showing it instead of stacking up a second one. That reuse is why the
152
+ # key has to be stable per file: it is the absolute path, hex-encoded
153
+ # because a path may hold anything a URL cannot.
154
+ tok=$(cat "$OPENER_TOKEN" 2>/dev/null || true)
155
+ if [ -z "$tok" ]; then
156
+ echo "acstack-md: no opener token at $OPENER_TOKEN." >&2
157
+ echo " run register-tasks.ps1 on the laptop to install one." >&2
158
+ exit 1
159
+ fi
160
+ hex() { od -An -v -tx1 | tr -d ' \n'; }
161
+ key=$(printf '%s' "$(cd "$(dirname "$FILE")" && pwd -P)/$(basename "$FILE")" | hex)
162
+ name=$(basename "$FILE")
163
+ # Vendored mermaid, uploaded once so diagrams draw without reaching a
164
+ # CDN. Best effort throughout: the opener redirects to the CDN when it
165
+ # has no copy, so a failure here costs nothing but an outbound fetch.
166
+ vendored="$HOME/.local/share/acstack/mermaid.min.js"
167
+ # tr, because whitespace inside JSON carries no meaning and a match that
168
+ # depends on its absence is a match waiting to break. Same reasoning as
169
+ # acstack-open's /json/list check.
170
+ if [ -s "$vendored" ] && ! curl -fsS -m 3 -H "X-ACStack-Token: $tok" \
171
+ "$OPENER/health" 2>/dev/null | tr -d '[:space:]' | grep -q '"mermaid":true'; then
172
+ curl -fsS -m 60 -X POST -H "X-ACStack-Token: $tok" \
173
+ --data-binary "@$vendored" "$OPENER/asset/mermaid.js" >/dev/null 2>&1 || true
174
+ fi
175
+ # The opener answers with what it did, and that answer is what success
176
+ # means - it says whether a tab was opened or a live one reused. An exit
177
+ # status of 0 is not a page that arrived.
178
+ answer=$(ACSTACK_MD_OPENER=1 "$0" --html "$FILE" \
179
+ | curl -fsS -m 60 -X POST -H "X-ACStack-Token: $tok" \
180
+ -H 'Content-Type: text/html; charset=utf-8' --data-binary @- \
181
+ "$OPENER/doc?key=$key&title=$(printf '%s' "$name" | hex)" 2>/dev/null \
182
+ | tr -d '[:space:]')
183
+ case "$answer" in
184
+ *'"reused":true'*)
185
+ printf '%s updated in the tab already open on your machine\n' "$name" >&2
186
+ exit 0 ;;
187
+ *'"opened":true'*)
188
+ printf '%s is on screen in the browser on your machine\n' "$name" >&2
189
+ exit 0 ;;
190
+ esac
191
+ echo "acstack-md: the opener did not confirm the page reached a browser." >&2
192
+ echo " check it is up: acstack-open --why" >&2
193
+ exit 1
194
+ ;;
195
+ bridge)
196
+ # Under `herdr --remote` the tunnel runs laptop-to-here, so nothing over
197
+ # there can open a port on this machine and a link to localhost:6419
198
+ # names the wrong localhost. Hand the whole document down the CDP link
199
+ # that is already open instead, and let Chrome hold it. That is also why
200
+ # --html exists: a document travels, a port does not.
201
+ #
202
+ # $0 is the path bash resolved this script to - absolute when it came off
203
+ # PATH - so the page pushed is the one --html renders, not a second copy
204
+ # of the renderer that can disagree with it.
205
+ html=$("$0" --html "$FILE") || exit 1
206
+ # base64 because the document is full of quotes and newlines and has to
207
+ # survive being a JavaScript string literal. tr rather than `base64 -w0`,
208
+ # which is GNU-only.
209
+ payload=$(printf '%s' "$html" | base64 | tr -d '\n')
210
+ # A tab of its own, and never over a page that is already there. The
211
+ # extension attaches per tab through chrome.debugger, and rewriting a
212
+ # real document detaches it - measured: pushing over an https page left
213
+ # /json/list empty and the bridge unusable until it was armed again by
214
+ # hand. A fresh about:blank tab survives the same rewrite, because the
215
+ # document being replaced has the same origin. So the armed tab is left
216
+ # exactly as it was, and this writes into a new one.
217
+ #
218
+ # The default session deliberately, as acstack-open explains: the relay
219
+ # takes one CDP client at a time, so a session of our own could not
220
+ # connect while an agent holds it.
221
+ #
222
+ # The last expression is the page title, so the browser answers with
223
+ # what it is now holding. That answer is what success means here - a
224
+ # command that exited 0 is not a page that arrived.
225
+ name=$(basename "$FILE")
226
+ timeout 20 agent-browser --cdp "$BRIDGE" tab new >/dev/null 2>&1
227
+ 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' \
228
+ "$payload" \
229
+ | timeout 60 agent-browser --cdp "$BRIDGE" eval --stdin 2>/dev/null)
230
+ # Writing into the tab is only half of it. A tab created over CDP is
231
+ # created in the background, so it has to be brought forward or the page
232
+ # is real, correct, and never seen - which is exactly how this first went
233
+ # wrong. agent-browser carries Page.bringToFront on its tab switch, so
234
+ # switch to the tab just written into, which is the one it already has
235
+ # selected. Best effort: a page in a background tab still beats an error.
236
+ # The arrow marks the active tab, which is the one just written into.
237
+ # Taking the first line instead would raise whatever sits at index 0 -
238
+ # somebody else's tab, and the page still unseen.
239
+ idx=$(timeout 20 agent-browser --cdp "$BRIDGE" tab list 2>/dev/null \
240
+ | grep -m1 -- "$(printf '\342\206\222')" \
241
+ | sed -n 's/.*\[\([0-9][0-9]*\)\].*/\1/p')
242
+ [ -n "$idx" ] && timeout 20 agent-browser --cdp "$BRIDGE" tab "$idx" >/dev/null 2>&1
243
+ case "$landed" in
244
+ *"$name"*)
245
+ printf '%s is on screen in the browser on your machine\n' "$name" >&2
246
+ exit 0 ;;
247
+ esac
248
+ # Serving instead would print a link the laptop resolves to its own
249
+ # localhost, which is a wrong answer dressed as a working one.
250
+ echo "acstack-md: the bridge answered but is not holding the page." >&2
251
+ echo " the document goes to the tab the Playwriter extension is armed on." >&2
252
+ echo " check something is still armed: acstack-open --why" >&2
253
+ exit 1
254
+ ;;
255
+ esac
256
+ # Every other route has a browser that can reach this machine's ports, or a
257
+ # terminal that can hand someone a link. Serving is already exactly that.
258
+ SERVE=1
259
+ fi
260
+
261
+ serve_or_html
262
+
263
+
264
+ # Terminal render: swap each mermaid fence for its text drawing, then page it.
265
+ ACSTACK_COLS="$COLS" ACSTACK_PAD="$PAD" ACSTACK_ONLY="$ONLY" ACSTACK_RUN="$$" \
266
+ ACSTACK_PREVIEW="$PREVIEW" \
267
+ python3 - "$FILE" <<'PY' > /tmp/acstack-md.$$.md 2>/dev/null
268
+ import os, pathlib, re, shlex, shutil, subprocess, sys, tempfile
269
+ text = pathlib.Path(sys.argv[1]).read_text()
270
+ # "$HOME/..." rather than the absolute path: shorter, and unlike ~'/x y'
271
+ # it expands correctly when the path contains spaces.
272
+ _raw = sys.argv[1]
273
+ _home = str(pathlib.Path.home())
274
+ SELF = ('"$HOME' + _raw[len(_home):].replace('"', '\\"') + '"'
275
+ if _raw.startswith(_home) else shlex.quote(_raw))
276
+ have = shutil.which("mermaid-ascii")
277
+ COLS = int(os.environ.get("ACSTACK_COLS") or 80)
278
+ PAD = (os.environ.get("ACSTACK_PAD") or "").split()
279
+ ONLY = int(os.environ.get("ACSTACK_ONLY") or 0)
280
+ PREVIEW = os.environ.get("ACSTACK_PREVIEW") == "1"
281
+ # glow indents and pads a fenced block, so the art has less room than the pane.
282
+ BUDGET = max(20, COLS - 6)
283
+ seen = 0
284
+
285
+ # mermaid-ascii understands `graph`/`flowchart` with [square] nodes. It draws
286
+ # styling directives as if they were nodes, and leaks any other shape syntax
287
+ # into the label, so both are normalised away before it sees the source.
288
+ DROP = re.compile(r"^\s*(style|classDef|class|linkStyle|click|%%)\b")
289
+ SHAPES = [
290
+ (re.compile(r"(\w+)\{\{(.+?)\}\}"), r"\1[\2]"), # {{hexagon}}
291
+ (re.compile(r"(\w+)\(\((.+?)\)\)"), r"\1[\2]"), # ((circle))
292
+ (re.compile(r"(\w+)\(\[(.+?)\]\)"), r"\1[\2]"), # ([stadium])
293
+ (re.compile(r"(\w+)\[\((.+?)\)\]"), r"\1[\2]"), # [(database)]
294
+ (re.compile(r"(\w+)\{(.+?)\}"), r"\1[\2]"), # {decision}
295
+ (re.compile(r"(\w+)\((.+?)\)"), r"\1[\2]"), # (rounded)
296
+ ]
297
+
298
+ def to_ascii(src):
299
+ if not have:
300
+ return None, "mermaid-ascii not installed"
301
+ body = "\n".join(l for l in src.splitlines() if not DROP.match(l))
302
+ for pat, rep in SHAPES:
303
+ body = pat.sub(rep, body)
304
+ if not re.match(r"\s*(graph|flowchart|sequenceDiagram)\b", body):
305
+ kind = (body.strip().split(None, 1) or ["?"])[0]
306
+ return None, f"{kind} diagrams are not supported by mermaid-ascii"
307
+ with tempfile.NamedTemporaryFile("w", suffix=".mmd", delete=False) as f:
308
+ f.write(body); path = f.name
309
+ # mermaid-ascii reads a file, not stdin. A preview redraws on every cursor
310
+ # move, so a file left behind here fills /tmp at cursor speed.
311
+ try:
312
+ r = subprocess.run(["mermaid-ascii", "--file", path] + PAD,
313
+ capture_output=True, text=True)
314
+ finally:
315
+ os.unlink(path)
316
+ if r.returncode != 0 or not r.stdout.strip():
317
+ return None, (r.stderr.strip().splitlines() or ["could not draw it"])[0]
318
+ return r.stdout.rstrip("\n"), None
319
+
320
+ def repl(m):
321
+ global seen
322
+ seen += 1
323
+ n = seen
324
+ art, err = to_ascii(m.group(1))
325
+ if not art:
326
+ # Keep the source visible rather than swallowing the diagram.
327
+ return (f"\n```\nDiagram {n} not drawn: {err}\n```\n\n"
328
+ f"```\n{m.group(1).rstrip()}\n```")
329
+ lines = art.splitlines()
330
+ width = max((len(l) for l in lines), default=0)
331
+ if ONLY:
332
+ # Raw, unwrapped, for a pager that can scroll sideways.
333
+ return "\0DIAGRAM%d\0%s\0" % (n, art) if n == ONLY else ""
334
+ if width > BUDGET:
335
+ # glow wraps whatever it renders, and wrapped box-drawing art is
336
+ # noise. Emit a marker instead and splice the raw art back in after
337
+ # glow has run, so prose wraps to the pane and the diagram does not.
338
+ run = os.environ.get("ACSTACK_RUN") or str(os.getpid())
339
+ # /tmp, not gettempdir(): the shell trap and the splicer both look
340
+ # there, and a stray $TMPDIR would hide the art from either.
341
+ art_path = pathlib.Path(f"/tmp/acstack-md-art.{run}.{n}")
342
+ art_path.write_text(art + "\n")
343
+ # The arrow keys belong to the pager. A preview pane has none, and
344
+ # opening the file is what draws the diagram there.
345
+ hint = "open it full width" if PREVIEW else "pans sideways with the arrow keys"
346
+ return f"\n```\n\x00ACSTACKART{n}\x00 {width} cols, {hint}\n```"
347
+ return "```\n" + art + "\n```"
348
+
349
+ out = re.sub(r"```mermaid\n(.*?)```", repl, text, flags=re.S)
350
+ if ONLY:
351
+ hit = re.search(r"\0DIAGRAM(\d+)\0(.*?)\0", out, re.S)
352
+ sys.stdout.write(hit.group(2) if hit else f"no diagram {ONLY} in this file\n")
353
+ else:
354
+ sys.stdout.write(out)
355
+ PY
356
+
357
+ if [ "$ONLY" != "0" ]; then
358
+ # -S chops instead of wrapping: arrow keys scroll sideways.
359
+ emit /tmp/acstack-md.$$.md less -SR
360
+ elif command -v glow >/dev/null 2>&1; then
361
+ # glow drops all styling when stdout is not a terminal, and its output has
362
+ # to be captured to splice the diagrams into, so it runs under a pty.
363
+ ACSTACK_COLS="$COLS" ACSTACK_STYLE="${ACSTACK_MD_STYLE:-}" \
364
+ python3 - /tmp/acstack-md.$$.md /tmp/acstack-md-out.$$.md <<'PY'
365
+ import fcntl, os, pathlib, pty, re, struct, subprocess, sys, termios
366
+ src, dst = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
367
+ cols = int(os.environ.get("ACSTACK_COLS") or 80)
368
+ # Leave glow on "auto": under a pty it picks its full palette. Pinning a
369
+ # named style here gives a noticeably flatter one.
370
+ style = os.environ.get("ACSTACK_STYLE") or ""
371
+
372
+ cmd = ["glow", "-w", str(cols - 2)] + (["--style", style] if style else []) + [str(src)]
373
+ master, slave = pty.openpty()
374
+ # glow reads the width from the pty, not just -w, so set it to match.
375
+ fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 200, min(cols, 65535), 0, 0))
376
+ proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=slave, stderr=subprocess.DEVNULL)
377
+ os.close(slave)
378
+ chunks = []
379
+ while True:
380
+ try:
381
+ b = os.read(master, 65536)
382
+ except OSError:
383
+ break
384
+ if not b:
385
+ break
386
+ chunks.append(b)
387
+ os.close(master); proc.wait()
388
+ raw = b"".join(chunks)
389
+ text = raw.decode("utf-8", "replace").replace("\r\n", "\n")
390
+ # A pty makes glow probe the terminal for its background colour. Those replies
391
+ # are meant for a real terminal, not a file, so drop them.
392
+ text = re.sub(r"\x1b\][01][01];\?(\x07|\x1b\\)", "", text)
393
+ text = re.sub(r"\x1b\[6n", "", text)
394
+ dst.write_text(text)
395
+ PY
396
+ splice /tmp/acstack-md-out.$$.md
397
+ emit /tmp/acstack-md-out.$$.md ${PAGER:-less -SR}
398
+ else
399
+ splice /tmp/acstack-md.$$.md
400
+ emit /tmp/acstack-md.$$.md ${PAGER:-less -R}
401
+ fi