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.
- package/.claude-plugin/plugin.json +1 -1
- package/assets/terminal-workspace/helpers/acstack-bridge +185 -0
- package/assets/terminal-workspace/helpers/acstack-clip +88 -0
- package/assets/terminal-workspace/helpers/acstack-dash +25 -0
- package/assets/terminal-workspace/helpers/acstack-db +86 -0
- package/assets/terminal-workspace/helpers/acstack-diff +268 -0
- package/assets/terminal-workspace/helpers/acstack-edit +25 -0
- package/assets/terminal-workspace/helpers/acstack-gh +47 -0
- package/assets/terminal-workspace/helpers/acstack-herdr-renumber +93 -0
- package/assets/terminal-workspace/helpers/acstack-herdr-tab +49 -0
- package/assets/terminal-workspace/helpers/acstack-md +401 -0
- package/assets/terminal-workspace/helpers/acstack-md-lib +219 -0
- package/assets/terminal-workspace/helpers/acstack-open +217 -0
- package/assets/terminal-workspace/helpers/acstack-popup +13 -0
- package/assets/terminal-workspace/helpers/acstack-view +33 -0
- package/assets/terminal-workspace/helpers/xclip +31 -0
- package/assets/terminal-workspace/lib/apply-ghdash.sh +78 -0
- package/assets/terminal-workspace/lib/apply-herdr-numbers.sh +49 -0
- package/assets/terminal-workspace/lib/apply-herdr.sh +308 -0
- package/assets/terminal-workspace/lib/apply-tools.sh +293 -0
- package/assets/terminal-workspace/lib/apply-tuicr.sh +133 -0
- package/assets/terminal-workspace/lib/apply-yazi.sh +389 -0
- package/assets/terminal-workspace/lib/commands.sh +303 -0
- package/assets/terminal-workspace/lib/core.sh +184 -0
- package/assets/terminal-workspace/lib/helper-install.sh +35 -0
- package/assets/terminal-workspace/lib/pi-packages.sh +360 -0
- package/assets/terminal-workspace/lib/pi-state.sh +479 -0
- package/assets/terminal-workspace/lib/versions.sh +254 -0
- package/assets/terminal-workspace/pi/acstack-question-blocked.ts +31 -0
- package/assets/terminal-workspace/setup.sh +25 -4637
- package/cli/package.json +1 -1
- package/docs/terminal-workspace.md +10 -1
- package/package.json +1 -1
- package/skills/handoff/SKILL.md +14 -14
- package/skills/handoff/launch-herdr.md +15 -4
- 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
|
+
}
|