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,219 @@
1
+ # A preview pane cannot page, so the same render goes straight to stdout.
2
+ emit() {
3
+ local f="$1" nk rc; shift
4
+ if [ "$PREVIEW" = "1" ]; then cat "$f"; return; fi
5
+ # The binding is a less feature, so only less is handed it. Setting
6
+ # LESSKEYIN for a pager that never reads it is at best noise, and acting on
7
+ # an exit status that pager chose for its own reasons is a bug waiting.
8
+ case "${1:-}" in
9
+ less|*/less)
10
+ # b is back-a-page in less. ^B and PageUp both still do that, so this
11
+ # costs nothing (measured). LESSKEYIN wants less 582 or newer; older
12
+ # versions ignore it and quietly keep b as it was.
13
+ #
14
+ # lesskey reads a line starting with # as a comment, so the key that
15
+ # means line numbers has to be escaped or the binding is silently
16
+ # dropped and the key does nothing but ring the bell - measured on less
17
+ # 590, and how this arrived as a bug report. Only a leading # needs it:
18
+ # backslash means something of its own to lesskey, and \b would bind
19
+ # backspace rather than the letter.
20
+ case "$NUMBER_KEY" in
21
+ '#'*) nk="\\$NUMBER_KEY" ;;
22
+ *) nk="$NUMBER_KEY" ;;
23
+ esac
24
+ { printf '#command\n'
25
+ printf '%s quit A\n' "$BROWSE_KEY"
26
+ printf '%s quit B\n' "$nk"
27
+ } > "$KEYS"
28
+ LESSKEYIN="$KEYS" "$@" "$f"
29
+ rc=$?
30
+ if [ "$rc" = "$BROWSE_STATUS" ]; then
31
+ "$0" --browser "$FILE"
32
+ exit $?
33
+ fi
34
+ if [ "$rc" = "$NUMBER_STATUS" ]; then
35
+ # One key, both directions: the view you are not in is the one it
36
+ # takes you to. $FILE is the markdown throughout, never the rendered
37
+ # temp copy, so either re-read starts from the source again.
38
+ if [ "$NUMBERED" = "1" ]; then "$0" "$FILE"; else "$0" --numbered "$FILE"; fi
39
+ exit $?
40
+ fi
41
+ ;;
42
+ *) "$@" "$f" ;;
43
+ esac
44
+ }
45
+
46
+ # A wide diagram left a marker behind rather than art glow would wrap. Turn
47
+ # the marker into a line a person can read and put the drawing back under it.
48
+ # Both output paths run this: without it the marker reaches the reader raw.
49
+ splice() {
50
+ ACSTACK_PID=$$ ACSTACK_PREVIEW="$PREVIEW" python3 - "$1" <<'PY'
51
+ import os, pathlib, re, sys
52
+ f = pathlib.Path(sys.argv[1])
53
+ pid = os.environ["ACSTACK_PID"]
54
+ preview = os.environ.get("ACSTACK_PREVIEW") == "1"
55
+ lines = []
56
+ for line in f.read_text().split("\n"):
57
+ m = re.search(r"\x00ACSTACKART(\d+)\x00", line)
58
+ if not m:
59
+ lines.append(line); continue
60
+ art = pathlib.Path(f"/tmp/acstack-md-art.{pid}.{m.group(1)}")
61
+ lines.append(re.sub(r"\x00ACSTACKART\d+\x00", f"Diagram {m.group(1)}:", line))
62
+ if art.exists():
63
+ # Art too wide for the pane would spill past it, so a preview pane
64
+ # keeps the placeholder and leaves the drawing to a full-width read.
65
+ if not preview:
66
+ lines.extend(" " + l for l in art.read_text().splitlines())
67
+ art.unlink()
68
+ f.write_text("\n".join(lines) + "\n")
69
+ PY
70
+ }
71
+
72
+ # Rendered markdown has no line numbers, and a reader who wants to quote a
73
+ # line back in a review needs the file's own. The gutter is as wide as the
74
+ # largest number in it, so the source stays on one column whatever the length.
75
+ numbered() { # numbered <file> -> stdout
76
+ local total width avail
77
+ total=$(awk 'END { print NR }' "$1")
78
+ width=${#total}
79
+ [ "$width" -lt 3 ] && width=3
80
+ if [ "$PREVIEW" = "1" ]; then
81
+ # A wrapped line makes the gutter lie about which source line you are
82
+ # looking at, and a pane has no arrow keys to pan with, so it chops.
83
+ avail=$((COLS - width - 2))
84
+ [ "$avail" -lt 1 ] && avail=1
85
+ awk -v w="$width" -v a="$avail" \
86
+ '{ printf "%" w "d %s\n", NR, substr($0, 1, a) }' "$1"
87
+ else
88
+ awk -v w="$width" '{ printf "%" w "d %s\n", NR, $0 }' "$1"
89
+ fi
90
+ }
91
+
92
+
93
+ serve_or_html() {
94
+ if [ "$SERVE" = "1" ] || [ "$HTML" = "1" ]; then
95
+ # One program, two ways out: --serve keeps answering with the document,
96
+ # --html hands it over once and stops. Same renderer either way, so the two
97
+ # cannot drift into disagreeing about what the page looks like.
98
+ MODE=serve; NAME=acstack-md-serve
99
+ if [ "$HTML" = "1" ]; then MODE=html; NAME=acstack-md-html; fi
100
+ # A document bound for the opener on the laptop is handed over once, like
101
+ # --html, but it does end up behind a server - the opener's. So it wants the
102
+ # relative mermaid src that --html cannot use. Internal: set by the --browser
103
+ # dispatch below, never by a caller.
104
+ if [ "${ACSTACK_MD_OPENER:-0}" = "1" ] && [ "$HTML" = "1" ]; then MODE=opener; fi
105
+ # exec -a names the process. Without it a running server is just "python3 -"
106
+ # and there is nothing sensible to pkill.
107
+ exec -a "$NAME" python3 - "$FILE" "$PORT" "$MODE" <<'PY'
108
+ import html, http.server, pathlib, re, socketserver, sys
109
+ try:
110
+ import markdown
111
+ except ImportError:
112
+ sys.exit("acstack-md: needs the python markdown package: pip install --user markdown")
113
+
114
+ src, port, MODE = pathlib.Path(sys.argv[1]), int(sys.argv[2]), sys.argv[3]
115
+ VENDOR = pathlib.Path.home() / ".local/share/acstack/mermaid.min.js"
116
+
117
+ CSS = """*{box-sizing:border-box}body{max-width:54rem;margin:0 auto;padding:2rem 1.25rem;
118
+ font:16px/1.65 -apple-system,Segoe UI,system-ui,sans-serif;color:#1a1a1a;background:#fff}
119
+ h1,h2,h3{line-height:1.25;margin:2rem 0 .75rem}h1{font-size:1.9rem}h2{font-size:1.45rem}
120
+ code{font:14px/1.5 ui-monospace,Consolas,monospace;background:#f2f2f2;padding:.15em .35em;border-radius:3px}
121
+ pre{background:#f7f7f7;padding:1rem;border-radius:6px;overflow-x:auto}pre code{background:none;padding:0}
122
+ pre.mermaid{background:none;text-align:center}table{border-collapse:collapse;width:100%}
123
+ th,td{border:1px solid #ddd;padding:.4rem .6rem;text-align:left}
124
+ blockquote{margin:1rem 0;padding:.1rem 1rem;border-left:3px solid #ccc;color:#555}
125
+ img{max-width:100%}
126
+ @media(prefers-color-scheme:dark){body{background:#151515;color:#e6e6e6}
127
+ code{background:#242424}pre{background:#1d1d1d}th,td{border-color:#333}
128
+ blockquote{border-color:#444;color:#aaa}}"""
129
+
130
+ def render():
131
+ body = markdown.markdown(src.read_text(),
132
+ extensions=["fenced_code", "tables", "toc", "sane_lists"])
133
+ # mermaid.js wants <pre class="mermaid">, not a highlighted code block.
134
+ body = re.sub(r'<pre><code class="language-mermaid">(.*?)</code></pre>',
135
+ lambda m: '<pre class="mermaid">' + html.unescape(m.group(1)) + "</pre>",
136
+ body, flags=re.S)
137
+ # --serve answers for /mermaid.js itself off the vendored copy. A document
138
+ # standing on its own has no server behind it, so a relative src would be
139
+ # fetched from whatever host the page ended up on and find nothing there -
140
+ # it names the CDN instead. Inlining the vendored 3.5MB would make the page
141
+ # offline-proof and far too big to push through a CDP payload, which is the
142
+ # whole reason --html exists.
143
+ # The opener serves the page from the laptop and answers /mermaid.js off
144
+ # the copy this machine uploaded to it - falling back to the CDN itself if
145
+ # it has none. So a relative src is right there too, and unlike --serve it
146
+ # does not depend on this machine having vendored anything.
147
+ js = ('<script src="/mermaid.js"></script>'
148
+ if MODE == "opener" or (MODE == "serve" and VENDOR.exists()) else
149
+ '<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>')
150
+ return f"""<!doctype html><html><head><meta charset="utf-8">
151
+ <meta name="viewport" content="width=device-width,initial-scale=1">
152
+ <title>{html.escape(src.name)}</title><style>{CSS}</style></head><body>
153
+ {body}
154
+ {js}
155
+ <script>mermaid.initialize({{startOnLoad:true,securityLevel:"loose",
156
+ theme:matchMedia("(prefers-color-scheme:dark)").matches?"dark":"default"}});</script>
157
+ </body></html>""".encode()
158
+
159
+ # Out before the port is ever looked at: a document is not a server, and a
160
+ # caller that only wants the page must not be stopped by a busy 6419.
161
+ if MODE in ("html", "opener"):
162
+ sys.stdout.buffer.write(render())
163
+ raise SystemExit
164
+
165
+ class H(http.server.BaseHTTPRequestHandler):
166
+ def do_GET(self):
167
+ if self.path == "/mermaid.js" and VENDOR.exists():
168
+ body, ctype = VENDOR.read_bytes(), "application/javascript"
169
+ else:
170
+ body, ctype = render(), "text/html; charset=utf-8" # re-read: edits show on reload
171
+ self.send_response(200)
172
+ self.send_header("Content-Type", ctype)
173
+ self.send_header("Content-Length", str(len(body)))
174
+ self.send_header("Cache-Control", "no-store")
175
+ self.end_headers()
176
+ self.wfile.write(body)
177
+ def log_message(self, *a): pass
178
+
179
+ socketserver.TCPServer.allow_reuse_address = True
180
+ def holder(port):
181
+ """pid and command line of whatever is listening, best effort."""
182
+ import subprocess
183
+ try:
184
+ out = subprocess.run(["ss", "-ltnpH", f"sport = :{port}"],
185
+ capture_output=True, text=True, timeout=3).stdout
186
+ pid = re.search(r"pid=(\d+)", out)
187
+ if not pid:
188
+ return None, None
189
+ pid = pid.group(1)
190
+ cmd = pathlib.Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ").decode()
191
+ return pid, cmd.strip()
192
+ except Exception:
193
+ return None, None
194
+
195
+ try:
196
+ srv = socketserver.TCPServer(("127.0.0.1", port), H)
197
+ except OSError as e:
198
+ pid, cmd = holder(port)
199
+ msg = [f"port {port} is busy: {e}"]
200
+ if pid:
201
+ mine = "acstack-md-serve" in cmd or "acstack-md" in cmd
202
+ msg.append(f" held by pid {pid}: {cmd[:90]}")
203
+ msg.append(f" stop it with: kill {pid}" if mine else
204
+ f" not acstack-md. use another port: ACSTACK_MD_PORT=<n> acstack-md --serve ...")
205
+ else:
206
+ msg.append(" could not identify the holder. try: ss -ltnp | grep " + str(port))
207
+ sys.exit("\n".join(msg))
208
+ url = f"http://localhost:{port}"
209
+ # OSC 8 makes it ctrl+clickable; the bare URL below covers terminals without it.
210
+ sys.stderr.write(f"\033]8;;{url}\033\\{src.name}\033]8;;\033\\ {url}\n")
211
+ sys.stderr.write(" reload the page after editing. ctrl+C to stop.\n")
212
+ if not VENDOR.exists():
213
+ sys.stderr.write(" note: mermaid.js not vendored, falling back to CDN\n")
214
+ try: srv.serve_forever()
215
+ except KeyboardInterrupt: pass
216
+ PY
217
+ fi
218
+
219
+ }
@@ -0,0 +1,217 @@
1
+ #!/usr/bin/env bash
2
+ # Open a URL from a machine that has no browser of its own.
3
+ #
4
+ # acstack-open https://github.com/owner/repo/pull/1
5
+ # acstack-open --why which route this machine will take, without taking it
6
+ #
7
+ # gh-dash binds o to this. It is bound as a keybinding rather than left to
8
+ # $BROWSER because gh-dash runs $BROWSER with its output discarded and the
9
+ # dashboard still drawn: a route that needs to say anything, or to hand you a
10
+ # link to click, has nowhere to put it. As a keybinding gh-dash steps aside
11
+ # and gives us the terminal.
12
+ #
13
+ # In order: an explicit override, a desktop on this machine, WSL, the opener
14
+ # service on the laptop, the Playwriter bridge to Chrome on the laptop, and
15
+ # last a link you can click.
16
+ # Installed by acstack.
17
+ set -uo pipefail
18
+
19
+ LOG="${XDG_CACHE_HOME:-$HOME/.cache}/acstack/open.log"
20
+ # An ambient $AGENT_BROWSER_CDP is a hint, never the thing we rely on. See
21
+ # the bridge branch below for what happened when it was.
22
+ BRIDGE="${ACSTACK_BRIDGE_URL:-${AGENT_BROWSER_CDP:-http://127.0.0.1:19988}}"
23
+ OPENER="${ACSTACK_OPENER_URL:-http://127.0.0.1:19989}"
24
+ # The opener refuses anything without this, so its absence is the same as the
25
+ # opener being down - which is exactly how the route probe treats it.
26
+ OPENER_TOKEN="${XDG_CONFIG_HOME:-$HOME/.config}/acstack/opener.token"
27
+
28
+ WHY=0
29
+ [ "${1:-}" = "--why" ] && { WHY=1; shift; }
30
+ URL="${1:-}"
31
+ [ -n "$URL" ] || [ "$WHY" = 1 ] \
32
+ || { echo "usage: acstack-open [--why] <url>" >&2; exit 2; }
33
+
34
+ log() { # the only record of what happened when the caller discards output
35
+ mkdir -p "$(dirname "$LOG")"
36
+ printf '%s %s\n' "$(date -Is)" "$*" >> "$LOG"
37
+ if [ "$(wc -l < "$LOG" 2>/dev/null || echo 0)" -gt 400 ]; then
38
+ tail -n 200 "$LOG" > "$LOG.trim" && mv "$LOG.trim" "$LOG"
39
+ fi
40
+ }
41
+
42
+ note() { # say something a suspended TUI cannot print for us
43
+ command -v herdr >/dev/null 2>&1 \
44
+ && herdr notification show "$1" --body "$2" >/dev/null 2>&1
45
+ return 0
46
+ }
47
+
48
+ # Not [ -t 1 ]: route() runs inside a command substitution for --why, which
49
+ # makes stdout a pipe even when the terminal is right there.
50
+ has_tty() { { : > /dev/tty; } 2>/dev/null; }
51
+
52
+ # The relay answering on its HTTP port is not the same as the browser being
53
+ # reachable through it. agent-browser quietly launches a headless Chrome on
54
+ # this machine when it cannot attach, and that Chrome navigates happily and
55
+ # shows nobody anything - which is how o came to report a page it had opened
56
+ # where no one could see it. So ask what is actually on the far end.
57
+ #
58
+ # /json/list is the Playwriter extension's own answer to that question. The
59
+ # extension attaches per tab, so `[]` means it is armed on nothing and there
60
+ # is no browser to drive, however healthy the tunnel underneath looks.
61
+ #
62
+ # This used to gate on $HOME/.agent-browser/default.sock existing first, on
63
+ # the reasoning that probing starts a browser as a side effect. It does not:
64
+ # curl starts nothing. What that gate did do was make the bridge unreachable,
65
+ # because nothing on this machine creates that socket until an agent-browser
66
+ # session is already running - so a perfectly healthy bridge still fell
67
+ # through to the link route. Asking the relay is both safer and correct.
68
+ bridge_attached() {
69
+ [ "${ACSTACK_OPEN_BRIDGE:-auto}" != "off" ] || return 1
70
+ # agent-browser has to exist to take the route, but is never run to decide
71
+ # it: deciding must cost one round trip, not a browser launch.
72
+ command -v agent-browser >/dev/null 2>&1 || return 1
73
+ # Seconds, not tens of them: the tunnel stays bound on this side after the
74
+ # relay at the far end stops answering, so an unhealthy bridge connects and
75
+ # then hangs rather than refusing. Every one of those seconds is spent in
76
+ # front of someone who just pressed a key.
77
+ local targets
78
+ targets=$(curl -fsS --connect-timeout 1 -m 2 "$BRIDGE/json/list" 2>/dev/null) || return 1
79
+ # Whitespace-only and [] both mean armed on no tab.
80
+ case "$(printf '%s' "$targets" | tr -d '[:space:]')" in
81
+ ''|'[]') return 1 ;;
82
+ esac
83
+ return 0
84
+ }
85
+
86
+ # Same discipline as bridge_attached: ask the far end rather than believing
87
+ # anything local. A token on disk proves nothing about a service being up, and
88
+ # a service being up proves nothing without the token it will demand - so the
89
+ # probe carries the token and the route is only taken if that round trip
90
+ # answered.
91
+ opener_ready() {
92
+ [ "${ACSTACK_OPEN_OPENER:-auto}" != "off" ] || return 1
93
+ local tok
94
+ tok=$(cat "$OPENER_TOKEN" 2>/dev/null) || return 1
95
+ [ -n "$tok" ] || return 1
96
+ # Seconds, not tens of them - someone just pressed a key and is watching.
97
+ curl -fsS --connect-timeout 1 -m 2 -H "X-ACStack-Token: $tok" \
98
+ "$OPENER/health" >/dev/null 2>&1
99
+ }
100
+
101
+ route() { # the route this machine will take, decided without taking it
102
+ [ -n "${ACSTACK_OPEN_CMD:-}" ] && { echo "command"; return; }
103
+ [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && command -v xdg-open >/dev/null 2>&1 \
104
+ && { echo "xdg-open"; return; }
105
+ local opener
106
+ for opener in wslview wsl-open explorer.exe; do
107
+ command -v "$opener" >/dev/null 2>&1 && { echo "$opener"; return; }
108
+ done
109
+ # Ahead of the bridge deliberately. The bridge drives a browser for an
110
+ # agent: it attaches per tab, only after someone clicks the extension icon,
111
+ # and it does so in a Chrome profile that is not the default browser. The
112
+ # opener needs no click and reaches the browser the user actually uses. See
113
+ # docs/adr/0006-document-opener-service.md.
114
+ opener_ready && { echo "opener"; return; }
115
+ bridge_attached && { echo "bridge"; return; }
116
+ has_tty && { echo "link"; return; }
117
+ echo "clipboard"
118
+ }
119
+
120
+ # A clickable link, on the terminal you are actually sitting at. OSC 8 is a
121
+ # hyperlink the terminal draws itself, so ctrl+click reaches the browser on
122
+ # your own machine with nothing installed anywhere in between. The bare URL
123
+ # on the next line covers terminals that ignore OSC 8, and the clipboard
124
+ # copy covers not wanting to click at all.
125
+ link_screen() {
126
+ printf '%s' "$URL" | acstack-clip
127
+ {
128
+ printf '\n Open on GitHub\n\n '
129
+ printf '\033]8;;%s\033\\%s\033]8;;\033\\\n' "$URL" "$URL"
130
+ printf ' ctrl+click the link, or paste it - it is on your clipboard\n\n'
131
+ printf ' Press any key to go back.'
132
+ } > /dev/tty
133
+ read -rsn1 < /dev/tty
134
+ printf '\n' > /dev/tty
135
+ }
136
+
137
+ ROUTE="$(route)"
138
+ [ "$WHY" = 1 ] && { echo "$ROUTE"; exit 0; }
139
+
140
+ case "$ROUTE" in
141
+ command)
142
+ log "override: $ACSTACK_OPEN_CMD $URL"
143
+ exec ${ACSTACK_OPEN_CMD} "$URL"
144
+ ;;
145
+ xdg-open)
146
+ # $BROWSER is cleared first: xdg-open reads it too, and $BROWSER may well
147
+ # be pointing back here, which would loop.
148
+ log "xdg-open: $URL"
149
+ BROWSER= exec xdg-open "$URL"
150
+ ;;
151
+ explorer.exe)
152
+ # explorer.exe reports failure even when it opened the page. Ignore it.
153
+ log "explorer.exe: $URL"
154
+ explorer.exe "$URL" >/dev/null 2>&1
155
+ exit 0
156
+ ;;
157
+ wslview|wsl-open)
158
+ log "$ROUTE: $URL"
159
+ exec "$ROUTE" "$URL"
160
+ ;;
161
+ opener)
162
+ # One POST and the laptop opens it in whatever it considers the default
163
+ # browser. No tab to arm, no extension, no CDP session to contend for.
164
+ #
165
+ # A URL carrying a double quote or a backslash would break out of the JSON
166
+ # string it is about to become. Rather than escape it, fall through to the
167
+ # link screen: those characters are not legal in a URL unescaped, so a URL
168
+ # holding one is malformed and worth showing a human rather than sending.
169
+ tok=$(cat "$OPENER_TOKEN" 2>/dev/null || true)
170
+ case "$URL" in
171
+ *'"'*|*'\\'*) tok="" ;;
172
+ esac
173
+ if [ -n "$tok" ] && curl -fsS --connect-timeout 2 -m 10 -X POST \
174
+ -H "X-ACStack-Token: $tok" -H 'Content-Type: application/json' \
175
+ --data "$(printf '{"url":"%s"}' "$URL")" \
176
+ "$OPENER/open" 2>/dev/null | tr -d '[:space:]' | grep -q '"opened":true'; then
177
+ log "opener $OPENER: $URL"
178
+ exit 0
179
+ fi
180
+ log "opener $OPENER did not open it, handing over the link: $URL"
181
+ if has_tty; then link_screen; exit 0; fi
182
+ ;;
183
+ bridge)
184
+ # Name the endpoint rather than inheriting one. Leaning on an ambient
185
+ # $AGENT_BROWSER_CDP made this launch a headless Chrome on the VM when
186
+ # run from a herdr popup, which reported success and opened nothing
187
+ # anybody could see.
188
+ #
189
+ # The default session, deliberately: the relay takes one CDP client at a
190
+ # time, so a session of our own cannot connect while an agent holds it.
191
+ # That also means the new tab becomes the active one for any agent
192
+ # driving that browser. ACSTACK_OPEN_BRIDGE=off trades the one-key open
193
+ # for never touching it.
194
+ if timeout 20 agent-browser --cdp "$BRIDGE" tab new >/dev/null 2>&1 \
195
+ && timeout 30 agent-browser --cdp "$BRIDGE" open "$URL" >/dev/null 2>&1; then
196
+ log "bridge $BRIDGE: $URL"
197
+ exit 0
198
+ fi
199
+ log "bridge $BRIDGE unavailable, handing over the link: $URL"
200
+ if has_tty; then link_screen; exit 0; fi
201
+ ;;
202
+ esac
203
+
204
+ [ "$ROUTE" = link ] && { log "link: $URL"; link_screen; exit 0; }
205
+
206
+ # No terminal to draw on and no browser to reach. The clipboard is still one
207
+ # paste from the browser on the machine you are sitting at, which beats
208
+ # nothing, but report failure anyway: a silent success here is what left
209
+ # gh-dash saying "Opened in browser" about a page that never opened.
210
+ if printf '%s' "$URL" | acstack-clip; then
211
+ log "copied, not opened: $URL"
212
+ note "URL copied, not opened" "No browser reachable from here. Paste: $URL"
213
+ exit 3
214
+ fi
215
+ log "no route: $URL"
216
+ note "Cannot open URL" "$URL"
217
+ exit 1
@@ -0,0 +1,13 @@
1
+ #!/bin/sh
2
+ # Run a herdr popup's command with this folder first on PATH.
3
+ #
4
+ # acstack-popup <command> [args...]
5
+ #
6
+ # herdr runs a popup with its server's environment, not a login shell. A
7
+ # server started by `herdr --remote` has the sshd default PATH, which lacks
8
+ # this folder, so a bare popup target exits 127. apply calls this helper by
9
+ # absolute path, and it finds its folder from $0. herdr and harlequin always
10
+ # link into ~/.local/bin, so that goes on PATH too. The target and every tool
11
+ # it starts then resolve whatever PATH the server has. Installed by acstack.
12
+ PATH="$(dirname "$0"):$HOME/.local/bin:$PATH"; export PATH
13
+ exec "$@"
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env bash
2
+ # View one file, picked by what the file is.
3
+ #
4
+ # acstack-view README.md rendered markdown, mermaid diagrams included
5
+ # acstack-view src/main.rs yazi, opened with the cursor on that file
6
+ #
7
+ # tuicr's <leader>v hands us the file under its cursor. Everything this
8
+ # dispatches to is a terminal program, so `q` hands the screen back to tuicr
9
+ # the same way a terminal editor does - which is the whole reason the choice
10
+ # lives in a script rather than in tuicr: changing it is an edit, not a
11
+ # rebuild.
12
+ # Installed by acstack.
13
+ set -uo pipefail
14
+
15
+ FILE="${1:-}"
16
+ [ -n "$FILE" ] || { echo "usage: acstack-view <file>" >&2; exit 2; }
17
+ [ -e "$FILE" ] || { echo "acstack-view: no such file: $FILE" >&2; exit 1; }
18
+
19
+ # Markdown is worth rendering rather than listing: acstack-md draws mermaid
20
+ # diagrams as text, and offers --serve for a browser when a diagram needs to
21
+ # be rendered properly.
22
+ case "$FILE" in
23
+ *.md|*.MD|*.markdown|*.Markdown|*.mdown|*.mkd)
24
+ command -v acstack-md >/dev/null 2>&1 && exec acstack-md "$FILE"
25
+ ;;
26
+ esac
27
+
28
+ # yazi opens on the file's own directory with the cursor on it, so the preview
29
+ # pane shows the file and the tree is right there to move around in.
30
+ command -v yazi >/dev/null 2>&1 && exec yazi "$FILE"
31
+
32
+ # Neither installed: a pager still beats nothing, and -R keeps any colour.
33
+ exec ${PAGER:-less -R} "$FILE"
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env bash
2
+ # xclip, for a machine whose clipboard is at the other end of an SSH session.
3
+ # acstack installs this only when no real xclip is present.
4
+ #
5
+ # It exists for programs that reach for xclip directly instead of offering a
6
+ # setting. gh-dash copies pull request URLs through a Go library that looks
7
+ # only for xclip, xsel, wl-copy and termux-clipboard-set, and gives up when
8
+ # none is on PATH. This puts one there, and acstack-clip sends the text to
9
+ # the terminal's own clipboard with OSC 52.
10
+ #
11
+ # Hands over to a real xclip if one turns up on PATH with a display to talk
12
+ # to. Remove it with `setup.sh uninstall`. Installed by acstack.
13
+ set -uo pipefail
14
+
15
+ PATH="$(cd "$(dirname "$0")" 2>/dev/null && pwd):$PATH"
16
+
17
+ # A real xclip, anywhere on PATH except beside this script.
18
+ if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then
19
+ self="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
20
+ while IFS= read -r candidate; do
21
+ [ "$(cd "$(dirname "$candidate")" 2>/dev/null && pwd)" = "$self" ] && continue
22
+ exec "$candidate" "$@"
23
+ done < <(type -ap xclip 2>/dev/null)
24
+ fi
25
+
26
+ for arg in "$@"; do
27
+ case "$arg" in
28
+ -o|-out|-output) exec acstack-clip --out ;;
29
+ esac
30
+ done
31
+ exec acstack-clip
@@ -0,0 +1,78 @@
1
+ apply_ghdash() {
2
+ have gh || { say "gh not installed, skipping dashboard"; return 0; }
3
+ gh extension list 2>/dev/null | grep -q gh-dash || gh extension install dlvhdr/gh-dash >/dev/null 2>&1
4
+ mkdir -p "$(dirname "$GHDASH_CFG")"
5
+ ACSTACK_CFG="$CFG" py "$GHDASH_CFG" <<'PY'
6
+ import os, sys, yaml
7
+ src, dst = os.environ["ACSTACK_CFG"], sys.argv[1]
8
+ # `or {}` on the section as well as the file. A bare "gh_dash:" with nothing
9
+ # under it, which is what commenting the block out leaves behind, parses to
10
+ # None, and every tw.get below then raises AttributeError. apply_ghdash died
11
+ # on that with a traceback and wrote no config at all. The keybindings and
12
+ # remote lookups below already guard the same way.
13
+ tw = ((yaml.safe_load(open(src)) or {}).get("gh_dash") or {}) if os.path.isfile(src) else {}
14
+ cfg = yaml.safe_load(open(dst)) or {} if os.path.isfile(dst) else {}
15
+ if tw.get("sections"):
16
+ cfg["prSections"] = [{"title": s["title"], "filters": s["filters"]} for s in tw["sections"]]
17
+ if tw.get("repo_paths"):
18
+ cfg["repoPaths"] = tw["repo_paths"]
19
+ kb = tw.get("keybindings", {}) or {}
20
+ # Every other setting reaches this script through cfg_get, which carries its
21
+ # own default. This block reads the yaml itself, so a machine with no
22
+ # terminal-workspace config used to land here with an empty dict: the prune
23
+ # below dropped the review and agent keys, and nothing was written back. D
24
+ # and S went silently dead while o, which already defaulted, kept working.
25
+ # Defaults here, matching config.example.yaml. An empty string still disables.
26
+ review = kb.get("review", "D")
27
+ agent_review = kb.get("agent_review", "S")
28
+ # "tree diff" is the old name for the review key, kept here so re-applying
29
+ # over a config written before the rename drops it rather than leaving two
30
+ # bindings on the same key.
31
+ MANAGED = ("review (tuicr)", "tree diff", "agent review", "open in browser")
32
+ prs = [k for k in (cfg.get("keybindings", {}) or {}).get("prs", [])
33
+ if k.get("name") not in MANAGED]
34
+ if review:
35
+ prs.append({"key": review, "name": "review (tuicr)",
36
+ "command": "cd {{.RepoPath}} && tuicr pr {{.PrNumber}}\n"})
37
+ if agent_review:
38
+ prs.append({"key": agent_review, "name": "agent review",
39
+ "command": 'cd {{.RepoPath}} && claude "/code-review {{.PrNumber}}"\n'})
40
+ kbs = cfg.setdefault("keybindings", {})
41
+ # o built into gh-dash opens through $BROWSER, whose output it discards. That
42
+ # is enough for a machine with a desktop and nothing at all for one without,
43
+ # where the only way to reach a browser is to hand the terminal a link. Take
44
+ # the key so acstack-open gets a terminal to draw on. GH_HOST covers
45
+ # GitHub Enterprise, whose URLs are the same shape on a different host.
46
+ open_key = (tw.get("remote", {}) or {}).get("open_key", "o")
47
+ if open_key:
48
+ host = "https://${GH_HOST:-github.com}"
49
+ prs.append({"key": open_key, "name": "open in browser",
50
+ "command": f'acstack-open "{host}/{{{{.RepoName}}}}/pull/{{{{.PrNumber}}}}"\n'})
51
+ issues = [k for k in (cfg.get("keybindings", {}) or {}).get("issues", [])
52
+ if k.get("name") not in MANAGED]
53
+ issues.append({"key": open_key, "name": "open in browser",
54
+ "command": f'acstack-open "{host}/{{{{.RepoName}}}}/issues/{{{{.IssueNumber}}}}"\n'})
55
+ kbs["issues"] = issues
56
+ kbs["prs"] = prs
57
+ if tw.get("page_keys", True):
58
+ uni = [k for k in kbs.get("universal", []) if k.get("builtin") not in ("pageUp", "pageDown")]
59
+ uni += [{"key": "pgup", "builtin": "pageUp"}, {"key": "pgdown", "builtin": "pageDown"}]
60
+ kbs["universal"] = uni
61
+ yaml.safe_dump(cfg, open(dst, "w"), sort_keys=False, default_flow_style=False, width=200)
62
+ print(" gh-dash config written")
63
+ PY
64
+ }
65
+
66
+ # Every edit the pi component makes to pi's own files goes through here, and
67
+ # so does the record of what it changed. pi's config is JSON, which has no
68
+ # comment to mark a managed region with, so ownership lives in $PI_OWNED.
69
+ # pkg <identity> the configured source pi holds for a package
70
+ # own|disown pkg|file <name> record or forget one thing this setup added
71
+ # owns pkg|file <name> exit 0 when this setup added it
72
+ # parses <file> exit 0 when the file is absent or valid JSON
73
+ # merge <file> <slot> <json> claim each dotted key with a value, and release
74
+ # each one set to null, putting back what it held
75
+ # pin <identity> <prior> <ours> record a pin apply moved, keeping the first prior
76
+ # pin-get <identity> the prior and our pin, tab-separated
77
+ # pin-forget <identity> drop that record
78
+ # md write|strip the delegation block in pi's context file
@@ -0,0 +1,49 @@
1
+ apply_herdr_numbers() {
2
+ have herdr || return 0
3
+ [ "$(cfg_get herdr.renumber_plugin true)" = "true" ] || return 0
4
+
5
+ local dir="$HOME/.config/acstack/herdr-numbers"
6
+ mkdir -p "$dir"
7
+ # An absolute command: herdr runs argv without a shell and resolves relative
8
+ # commands from the plugin root, not from PATH.
9
+ cat > "$dir/herdr-plugin.toml" <<H
10
+ id = "acstack.herdr-numbers"
11
+ name = "ACStack workspace numbers"
12
+ version = "1.0.0"
13
+ min_herdr_version = "0.8.0"
14
+ description = "Keep the sidebar numbers matching prefix+1..9"
15
+ platforms = ["linux", "macos", "windows"]
16
+
17
+ # Every event that can move a sidebar row. workspace.moved and
18
+ # workspace.reordered are deliberately absent: the realignment emits both, so
19
+ # hooking them would loop forever. The helper is idempotent, so the overlap
20
+ # between the workspace and worktree events costs nothing.
21
+ [[events]]
22
+ on = "workspace.created"
23
+ command = ["$BIN/acstack-herdr-renumber"]
24
+
25
+ [[events]]
26
+ on = "workspace.closed"
27
+ command = ["$BIN/acstack-herdr-renumber"]
28
+
29
+ [[events]]
30
+ on = "worktree.created"
31
+ command = ["$BIN/acstack-herdr-renumber"]
32
+
33
+ [[events]]
34
+ on = "worktree.opened"
35
+ command = ["$BIN/acstack-herdr-renumber"]
36
+
37
+ [[events]]
38
+ on = "worktree.removed"
39
+ command = ["$BIN/acstack-herdr-renumber"]
40
+ H
41
+
42
+ if herdr plugin list 2>/dev/null | grep -q "acstack.herdr-numbers"; then
43
+ say "herdr numbers plugin already linked"
44
+ elif herdr plugin link "$dir" >/dev/null 2>&1; then
45
+ say "herdr numbers plugin linked, sidebar numbers stay aligned"
46
+ else
47
+ say "herdr plugin link failed; run acstack-herdr-renumber by hand"
48
+ fi
49
+ }