acstack 0.48.5 → 0.49.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +296 -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 +331 -0
- package/assets/terminal-workspace/lib/pi-state.sh +479 -0
- package/assets/terminal-workspace/lib/versions.sh +254 -0
- package/assets/terminal-workspace/setup.sh +24 -4637
- package/cli/dist/index.js +8 -4
- package/cli/dist/index.js.map +2 -2
- package/cli/package.json +1 -1
- package/package.json +1 -1
- package/skills/archive/SKILL.md +3 -0
- package/skills/grilling/SKILL.md +2 -1
- package/skills/handoff/SKILL.md +14 -14
- package/skills/handoff/launch-herdr.md +15 -4
- package/skills/implement/SKILL.md +19 -3
- package/skills/map/SKILL.md +24 -24
- package/skills/map/alignment.md +38 -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
|