acstack 0.49.0 → 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/package.json +1 -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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "acstack",
|
|
3
3
|
"description": "Agentic coding stack where planning grows only as far as needed: a map of decisions, tasks, checks, and handoffs behind a pluggable tracker.",
|
|
4
|
-
"version": "0.49.
|
|
4
|
+
"version": "0.49.1",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "ac8318740"
|
|
7
7
|
},
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Look at the Playwriter bridge from the dev machine, and fix it where that is
|
|
3
|
+
# possible from here.
|
|
4
|
+
#
|
|
5
|
+
# acstack-bridge status what is up, on both machines
|
|
6
|
+
# acstack-bridge fix [what] restart relay | tunnel | both (default both)
|
|
7
|
+
#
|
|
8
|
+
# Restarting either is a Windows scheduled task, so this machine cannot do it
|
|
9
|
+
# directly. It asks the opener, which runs over there and can. When the opener
|
|
10
|
+
# is not reachable either, it prints the handoff block to paste into a
|
|
11
|
+
# PowerShell on the laptop instead - the same block the bridge skill defines.
|
|
12
|
+
#
|
|
13
|
+
# What it still cannot do is arm the extension. That is a click inside a
|
|
14
|
+
# third-party extension and nothing on either machine can press it.
|
|
15
|
+
# Installed by acstack.
|
|
16
|
+
set -uo pipefail
|
|
17
|
+
|
|
18
|
+
BRIDGE="${ACSTACK_BRIDGE_URL:-http://127.0.0.1:19988}"
|
|
19
|
+
OPENER="${ACSTACK_OPENER_URL:-http://127.0.0.1:19989}"
|
|
20
|
+
OPENER_TOKEN="${XDG_CONFIG_HOME:-$HOME/.config}/acstack/opener.token"
|
|
21
|
+
FREE_PORT="${ACSTACK_FREE_PORT:-$HOME/.claude/acstack/bin/vm-free-port.sh}"
|
|
22
|
+
|
|
23
|
+
CMD="${1:-status}"
|
|
24
|
+
WHAT="${2:-both}"
|
|
25
|
+
|
|
26
|
+
tok() { cat "$OPENER_TOKEN" 2>/dev/null; }
|
|
27
|
+
|
|
28
|
+
clear_port() { # clear_port <port> - let go of a forward this machine still holds
|
|
29
|
+
# A tunnel that will not rebind is usually held from this side: the VM's sshd
|
|
30
|
+
# still owns the forward channel of a session that dropped. Restarting the
|
|
31
|
+
# task on the laptop then fails exactly the way it failed before, because
|
|
32
|
+
# nothing here let go of the port. So this runs first.
|
|
33
|
+
#
|
|
34
|
+
# The clearer ships with acstack and may simply not be installed on a machine
|
|
35
|
+
# that got the terminal workspace some other way. Worth saying, but not a
|
|
36
|
+
# reason to refuse the restart, which may be all that was needed.
|
|
37
|
+
if [ ! -f "$FREE_PORT" ]; then
|
|
38
|
+
echo "no port clearer at $FREE_PORT - asking for the restart without freeing $1 first." >&2
|
|
39
|
+
return 0
|
|
40
|
+
fi
|
|
41
|
+
#
|
|
42
|
+
# A clearer that predates --port is the awkward case in between: it exists,
|
|
43
|
+
# so the missing-file warning never fires, but it only knows how to free
|
|
44
|
+
# 19988. Called bare it would clear the bridge port twice, never touch 19989,
|
|
45
|
+
# and hand back two successes - so the caller would believe both ports were
|
|
46
|
+
# freed. Report the version and clear nothing; guessing is worse than saying.
|
|
47
|
+
# It announces its age two ways: the flag is nowhere in its text, or it takes
|
|
48
|
+
# the flag, does not recognise the argument, and exits 64 - the usage code,
|
|
49
|
+
# which is not "the port is stuck" and is not worth a retry without the flag.
|
|
50
|
+
local older="is older than this branch, so it cannot free $1 on request."
|
|
51
|
+
local fix="Update acstack on this machine to refresh it. Asking for the restart anyway."
|
|
52
|
+
if ! grep -q -- '--port' "$FREE_PORT" 2>/dev/null; then
|
|
53
|
+
echo "$FREE_PORT $older It has no --port support at all. $fix" >&2
|
|
54
|
+
return 0
|
|
55
|
+
fi
|
|
56
|
+
local rc=0
|
|
57
|
+
bash "$FREE_PORT" --port "$1" >/dev/null 2>&1 || rc=$?
|
|
58
|
+
if [ "$rc" = "64" ]; then
|
|
59
|
+
echo "$FREE_PORT $older It refused --port with the usage code 64. $fix" >&2
|
|
60
|
+
elif [ "$rc" != "0" ]; then
|
|
61
|
+
echo "$FREE_PORT could not free $1 - asking for the restart anyway." >&2
|
|
62
|
+
fi
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
opener_call() { # opener_call <method> <path> [data]
|
|
66
|
+
local t; t=$(tok) || return 1
|
|
67
|
+
[ -n "$t" ] || return 1
|
|
68
|
+
if [ "$1" = "POST" ]; then
|
|
69
|
+
curl -fsS --connect-timeout 2 -m 40 -X POST -H "X-ACStack-Token: $t" \
|
|
70
|
+
-H 'Content-Type: application/json' --data "${3:-{\}}" "$OPENER$2" 2>/dev/null
|
|
71
|
+
else
|
|
72
|
+
curl -fsS --connect-timeout 2 -m 10 -H "X-ACStack-Token: $t" "$OPENER$2" 2>/dev/null
|
|
73
|
+
fi
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
handoff() { # what this machine cannot do, in the shape the other side expects
|
|
77
|
+
# Unquoted, because $1 names what is being asked for - so every PowerShell $
|
|
78
|
+
# inside has to be escaped or the shell eats it. A handoff block is pasted
|
|
79
|
+
# verbatim into a shell on the other machine, so a mangled one is worse than
|
|
80
|
+
# no block at all.
|
|
81
|
+
cat >&2 <<HANDOFF
|
|
82
|
+
|
|
83
|
+
--- BEGIN VM-SIDE HANDOFF (to Windows agent) ---
|
|
84
|
+
Context: the bridge needs $1 on the laptop, and the opener on 19989 is not
|
|
85
|
+
reachable from this machine either, so nothing here can do it.
|
|
86
|
+
|
|
87
|
+
Run on the Windows laptop:
|
|
88
|
+
Stop-ScheduledTask -TaskName 'Playwriter-*'
|
|
89
|
+
Start-ScheduledTask -TaskName 'Playwriter-Relay'
|
|
90
|
+
Start-ScheduledTask -TaskName 'Playwriter-Opener'
|
|
91
|
+
Get-ScheduledTask -TaskName 'Playwriter-Tunnel-*','Playwriter-OpenerTunnel-*' |
|
|
92
|
+
ForEach-Object { Start-ScheduledTask -TaskName \$_.TaskName }
|
|
93
|
+
.\doctor.ps1
|
|
94
|
+
|
|
95
|
+
Expected result:
|
|
96
|
+
doctor.ps1 exits with all green rows, including "Relay listening on 19988"
|
|
97
|
+
and "Opener listening on 19989", and no tunnel-*.stuck markers.
|
|
98
|
+
|
|
99
|
+
Report back:
|
|
100
|
+
The doctor.ps1 output (paste the table).
|
|
101
|
+
--- END VM-SIDE HANDOFF ---
|
|
102
|
+
HANDOFF
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case "$CMD" in
|
|
106
|
+
status)
|
|
107
|
+
# This machine's own view first: these say whether the tunnels arrived,
|
|
108
|
+
# which is the half the laptop cannot see.
|
|
109
|
+
if curl -fsS --connect-timeout 1 -m 3 "$BRIDGE/json/version" >/dev/null 2>&1; then
|
|
110
|
+
armed=$(curl -fsS --connect-timeout 1 -m 3 "$BRIDGE/json/list" 2>/dev/null | tr -d '[:space:]')
|
|
111
|
+
case "$armed" in
|
|
112
|
+
''|'[]') echo "relay: reachable, but the extension is armed on no tab" ;;
|
|
113
|
+
*) echo "relay: reachable, extension armed" ;;
|
|
114
|
+
esac
|
|
115
|
+
else
|
|
116
|
+
echo "relay: not reachable on $BRIDGE"
|
|
117
|
+
fi
|
|
118
|
+
|
|
119
|
+
if health=$(opener_call GET /health); then
|
|
120
|
+
echo "opener: reachable - $(printf '%s' "$health" | tr -d '[:space:]')"
|
|
121
|
+
elif [ -z "$(tok)" ]; then
|
|
122
|
+
echo "opener: no token at $OPENER_TOKEN (run register-tasks.ps1 on the laptop)"
|
|
123
|
+
else
|
|
124
|
+
echo "opener: not reachable on $OPENER"
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
# And the laptop's view, which only the opener can fetch.
|
|
128
|
+
if tasks=$(opener_call GET /bridge/health); then
|
|
129
|
+
echo "tasks: $(printf '%s' "$tasks" | tr -d '[:space:]')"
|
|
130
|
+
else
|
|
131
|
+
echo "tasks: unknown - the opener is the only way to see them from here"
|
|
132
|
+
fi
|
|
133
|
+
echo "route: $(acstack-open --why 2>/dev/null || echo unknown)"
|
|
134
|
+
;;
|
|
135
|
+
fix)
|
|
136
|
+
case "$WHAT" in
|
|
137
|
+
relay|tunnel|both) ;;
|
|
138
|
+
*) echo "usage: acstack-bridge fix [relay|tunnel|both]" >&2; exit 2 ;;
|
|
139
|
+
esac
|
|
140
|
+
# A tunnel restart is the only case where a port here is in the way. Both
|
|
141
|
+
# ports get cleared, because the opener's 19989 is forwarded by its own task
|
|
142
|
+
# and wedges on its own. fix relay touches no tunnel, so clearing a port
|
|
143
|
+
# there would be tearing down a working connection to fix something else.
|
|
144
|
+
if [ "$WHAT" != "relay" ]; then
|
|
145
|
+
clear_port 19988
|
|
146
|
+
clear_port 19989
|
|
147
|
+
fi
|
|
148
|
+
if out=$(opener_call POST /bridge/restart "$(printf '{"what":"%s"}' "$WHAT")"); then
|
|
149
|
+
echo "asked the laptop to restart: $WHAT"
|
|
150
|
+
printf '%s\n' "$out"
|
|
151
|
+
# Restarting is not recovering. Give the tunnels a moment to rebind, then
|
|
152
|
+
# say whether they actually came back rather than reporting the request.
|
|
153
|
+
sleep 5
|
|
154
|
+
rc=0
|
|
155
|
+
if curl -fsS --connect-timeout 1 -m 3 "$BRIDGE/json/version" >/dev/null 2>&1; then
|
|
156
|
+
echo "relay is answering again on $BRIDGE"
|
|
157
|
+
else
|
|
158
|
+
echo "the restart was accepted but the relay is still not answering here on 19988." >&2
|
|
159
|
+
echo " if this persists the port may be wedged on this machine:" >&2
|
|
160
|
+
echo " bash $FREE_PORT --port 19988" >&2
|
|
161
|
+
rc=1
|
|
162
|
+
fi
|
|
163
|
+
# Two tunnels, two verdicts. A relay that came back says nothing about
|
|
164
|
+
# the opener, and an opener that stayed down is the half that leaves this
|
|
165
|
+
# machine unable to reach a browser at all - so it is checked, and named.
|
|
166
|
+
if [ "$WHAT" != "relay" ]; then
|
|
167
|
+
if opener_call GET /health >/dev/null; then
|
|
168
|
+
echo "opener is answering again on $OPENER"
|
|
169
|
+
else
|
|
170
|
+
echo "the restart was accepted but the opener is still not answering here on 19989." >&2
|
|
171
|
+
echo " if this persists the port may be wedged on this machine:" >&2
|
|
172
|
+
echo " bash $FREE_PORT --port 19989" >&2
|
|
173
|
+
rc=1
|
|
174
|
+
fi
|
|
175
|
+
fi
|
|
176
|
+
exit $rc
|
|
177
|
+
fi
|
|
178
|
+
handoff "a restart of $WHAT"
|
|
179
|
+
exit 1
|
|
180
|
+
;;
|
|
181
|
+
*)
|
|
182
|
+
echo "usage: acstack-bridge [status|fix [relay|tunnel|both]]" >&2
|
|
183
|
+
exit 2
|
|
184
|
+
;;
|
|
185
|
+
esac
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Put text on the clipboard of the machine your terminal is on.
|
|
3
|
+
#
|
|
4
|
+
# acstack-clip "text" copy the arguments
|
|
5
|
+
# ... | acstack-clip copy stdin
|
|
6
|
+
# acstack-clip --out print what was copied last
|
|
7
|
+
#
|
|
8
|
+
# A VM reached over SSH has no display and no clipboard of its own, so
|
|
9
|
+
# xclip and friends have nothing to talk to. OSC 52 is the escape sequence
|
|
10
|
+
# that asks the terminal at the far end - Windows Terminal, iTerm2, kitty -
|
|
11
|
+
# to put text on its own clipboard. It is only bytes in the terminal stream,
|
|
12
|
+
# so it crosses SSH for free, and herdr forwards it from a pane to whatever
|
|
13
|
+
# terminal is hosting it.
|
|
14
|
+
#
|
|
15
|
+
# Reading back is not symmetrical. Windows Terminal refuses OSC 52 reads on
|
|
16
|
+
# purpose, so --out replays a local cache instead. Installed by acstack.
|
|
17
|
+
set -uo pipefail
|
|
18
|
+
|
|
19
|
+
CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/acstack/clipboard"
|
|
20
|
+
|
|
21
|
+
# A real clipboard beats an escape sequence when one exists. Anything sitting
|
|
22
|
+
# in this script's own directory is skipped, so the xclip shim installed
|
|
23
|
+
# beside it can never call back into here in a loop.
|
|
24
|
+
native() { # native <tool>; prints the path of a real one, if any
|
|
25
|
+
local tool="$1" self candidate
|
|
26
|
+
self="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
|
|
27
|
+
while IFS= read -r candidate; do
|
|
28
|
+
[ "$(cd "$(dirname "$candidate")" 2>/dev/null && pwd)" = "$self" ] && continue
|
|
29
|
+
printf '%s' "$candidate"; return 0
|
|
30
|
+
done < <(type -ap "$tool" 2>/dev/null)
|
|
31
|
+
return 1
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
has_display() { [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; }
|
|
35
|
+
|
|
36
|
+
if [ "${1:-}" = "--out" ] || [ "${1:-}" = "-o" ]; then
|
|
37
|
+
if has_display; then
|
|
38
|
+
for tool in wl-paste xclip xsel; do
|
|
39
|
+
real="$(native "$tool")" || continue
|
|
40
|
+
case "$tool" in
|
|
41
|
+
wl-paste) exec "$real" --no-newline ;;
|
|
42
|
+
xclip) exec "$real" -out -selection clipboard ;;
|
|
43
|
+
xsel) exec "$real" --output --clipboard ;;
|
|
44
|
+
esac
|
|
45
|
+
done
|
|
46
|
+
fi
|
|
47
|
+
[ -f "$CACHE" ] || { echo "acstack-clip: nothing copied yet" >&2; exit 1; }
|
|
48
|
+
cat "$CACHE"
|
|
49
|
+
exit 0
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
if [ $# -gt 0 ]; then text="$*"; else text="$(cat)"; fi
|
|
53
|
+
|
|
54
|
+
mkdir -p "$(dirname "$CACHE")"
|
|
55
|
+
printf '%s' "$text" > "$CACHE"
|
|
56
|
+
chmod 600 "$CACHE" 2>/dev/null
|
|
57
|
+
|
|
58
|
+
if has_display; then
|
|
59
|
+
for tool in wl-copy xclip xsel; do
|
|
60
|
+
real="$(native "$tool")" || continue
|
|
61
|
+
case "$tool" in
|
|
62
|
+
wl-copy) printf '%s' "$text" | "$real" && exit 0 ;;
|
|
63
|
+
xclip) printf '%s' "$text" | "$real" -in -selection clipboard && exit 0 ;;
|
|
64
|
+
xsel) printf '%s' "$text" | "$real" --input --clipboard && exit 0 ;;
|
|
65
|
+
esac
|
|
66
|
+
done
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
b64="$(printf '%s' "$text" | base64 | tr -d '\n')"
|
|
70
|
+
# 74994 bytes is the ceiling tmux puts on an OSC 52 payload, and the lowest
|
|
71
|
+
# of anything in this path. Past it the sequence is dropped without a word,
|
|
72
|
+
# so say so rather than report a copy that did not happen.
|
|
73
|
+
if [ ${#b64} -gt 74994 ]; then
|
|
74
|
+
echo "acstack-clip: too large for the terminal clipboard (${#b64} bytes encoded)" >&2
|
|
75
|
+
exit 1
|
|
76
|
+
fi
|
|
77
|
+
|
|
78
|
+
esc=$'\033]52;c;'"$b64"$'\a'
|
|
79
|
+
# tmux drops escape sequences it does not recognise unless they are wrapped
|
|
80
|
+
# for passthrough. herdr needs no wrapping - it forwards OSC 52 itself.
|
|
81
|
+
if [ -n "${TMUX:-}" ]; then
|
|
82
|
+
esc=$'\033Ptmux;'"${esc//$'\033'/$'\033\033'}"$'\033\\'
|
|
83
|
+
fi
|
|
84
|
+
|
|
85
|
+
# The controlling terminal, not stdout. Callers are usually TUIs that have
|
|
86
|
+
# redirected both streams, and OSC 52 has to reach the terminal itself.
|
|
87
|
+
if { printf '%s' "$esc" > /dev/tty; } 2>/dev/null; then exit 0; fi
|
|
88
|
+
printf '%s' "$esc" >&2
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# gh-dash with a section for the repo you are standing in. Installed by acstack.
|
|
3
|
+
set -uo pipefail
|
|
4
|
+
# Every action gh-dash takes shells out to gh and discards its stderr, so a
|
|
5
|
+
# refusal from GitHub reaches you as "exit status 1" for two seconds. acstack-gh
|
|
6
|
+
# goes in front of the real gh under that name and speaks the refusal aloud.
|
|
7
|
+
# Its directory holds nothing else, so nothing else on PATH is shadowed.
|
|
8
|
+
SHIM="$(mktemp -d)"; GEN=""
|
|
9
|
+
trap 'rm -rf "$SHIM"; rm -f "$GEN"' EXIT
|
|
10
|
+
ln -s "$(command -v acstack-gh)" "$SHIM/gh" 2>/dev/null && export PATH="$SHIM:$PATH"
|
|
11
|
+
BASE="$HOME/.config/gh-dash/config.yml"
|
|
12
|
+
REPO="$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null)"
|
|
13
|
+
# Not exec, so the trap above still gets to clean up after the dashboard.
|
|
14
|
+
[ -z "$REPO" ] && { gh dash "$@"; exit $?; }
|
|
15
|
+
GEN="$(mktemp)"
|
|
16
|
+
REPO="$REPO" python3 - "$BASE" "$GEN" <<'PY'
|
|
17
|
+
import os, sys, yaml
|
|
18
|
+
cfg = yaml.safe_load(open(sys.argv[1])) or {}
|
|
19
|
+
repo = os.environ["REPO"]; short = repo.split("/")[-1]
|
|
20
|
+
s = [x for x in cfg.get("prSections", []) if x.get("title") != short]
|
|
21
|
+
s.insert(0, {"title": short, "filters": f"repo:{repo} is:open"})
|
|
22
|
+
cfg["prSections"] = s
|
|
23
|
+
yaml.safe_dump(cfg, open(sys.argv[2], "w"), sort_keys=False)
|
|
24
|
+
PY
|
|
25
|
+
gh dash --config "$GEN" "$@"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Open harlequin, the SQL editor, on one of your connection profiles.
|
|
3
|
+
#
|
|
4
|
+
# acstack-db one profile launches straight in, more than one opens a menu
|
|
5
|
+
# acstack-db <profile> that profile, no menu
|
|
6
|
+
#
|
|
7
|
+
# Profiles live in a TOML file of your own. This writes a starter the first
|
|
8
|
+
# time and never touches it again. Installed by acstack.
|
|
9
|
+
set -uo pipefail
|
|
10
|
+
# apply rewrites this line from harlequin.config_path. What ships here is the
|
|
11
|
+
# default, so the helper is right even on a machine nothing rewrote it on.
|
|
12
|
+
DB_CFG_DEFAULT="~/.config/acstack/harlequin.toml" # acstack:config_path
|
|
13
|
+
CFG="${ACSTACK_DB_CONFIG:-$DB_CFG_DEFAULT}"
|
|
14
|
+
# A human writes ~ for home, and a human writes this config. Expanding it here
|
|
15
|
+
# rather than at write time keeps the helper right on a machine whose HOME is
|
|
16
|
+
# not the one apply ran under.
|
|
17
|
+
case "$CFG" in
|
|
18
|
+
"~") CFG="$HOME" ;;
|
|
19
|
+
"~/"*) CFG="$HOME/${CFG#\~/}" ;;
|
|
20
|
+
esac
|
|
21
|
+
|
|
22
|
+
command -v harlequin >/dev/null 2>&1 || {
|
|
23
|
+
echo "harlequin is not installed. Run: setup.sh apply"; exit 1; }
|
|
24
|
+
|
|
25
|
+
if [ ! -f "$CFG" ]; then
|
|
26
|
+
mkdir -p "$(dirname "$CFG")"
|
|
27
|
+
cat > "$CFG" <<'T'
|
|
28
|
+
# harlequin connection profiles. acstack wrote this once and will not write it
|
|
29
|
+
# again. Edit it freely.
|
|
30
|
+
default_profile = "local"
|
|
31
|
+
|
|
32
|
+
[profiles.local]
|
|
33
|
+
adapter = "postgres"
|
|
34
|
+
conn_str = "postgres://localhost:5432/postgres"
|
|
35
|
+
|
|
36
|
+
# Snowflake. Take the # marks off and fill in your own account.
|
|
37
|
+
#
|
|
38
|
+
# Put the credentials in ~/.snowflake/connections.toml and name the connection
|
|
39
|
+
# here, rather than writing them into this file.
|
|
40
|
+
#
|
|
41
|
+
# externalbrowser authentication needs a browser on this machine. A dev machine
|
|
42
|
+
# reached over SSH has none, so use a key pair or a password instead.
|
|
43
|
+
#
|
|
44
|
+
# [profiles.snowflake]
|
|
45
|
+
# adapter = "snowflake"
|
|
46
|
+
# connection_name = "default"
|
|
47
|
+
T
|
|
48
|
+
echo "wrote a starter config at $CFG - edit it to reach your own database"
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
launch() { exec harlequin --config-path "$CFG" -P "$1"; }
|
|
52
|
+
[ $# -gt 0 ] && launch "$1"
|
|
53
|
+
|
|
54
|
+
# Profile names, read with a regex rather than tomllib: tomllib needs python
|
|
55
|
+
# 3.11 and nothing else this script writes has a floor that high. Names are all
|
|
56
|
+
# that is wanted here, and a table header is the one line a regex reads safely.
|
|
57
|
+
rows=$(python3 - "$CFG" <<'PY'
|
|
58
|
+
import re, sys
|
|
59
|
+
text = open(sys.argv[1]).read()
|
|
60
|
+
d = re.search(r'(?m)^\s*default_profile\s*=\s*"([^"]+)"', text)
|
|
61
|
+
default = d.group(1) if d else ""
|
|
62
|
+
for m in re.finditer(r'(?m)^\s*\[profiles\.("?)([^\]"]+)\1\]\s*$', text):
|
|
63
|
+
name = m.group(2)
|
|
64
|
+
body = text[m.end():].split("\n[", 1)[0]
|
|
65
|
+
a = re.search(r'(?m)^\s*adapter\s*=\s*"([^"]+)"', body)
|
|
66
|
+
mark = " (default)" if name == default else ""
|
|
67
|
+
print("%s\t%-24s %-12s%s" % (name, name, a.group(1) if a else "?", mark))
|
|
68
|
+
PY
|
|
69
|
+
)
|
|
70
|
+
count=$(printf '%s\n' "$rows" | grep -c .)
|
|
71
|
+
case "$count" in
|
|
72
|
+
0) echo "no profiles in $CFG"; exit 1 ;;
|
|
73
|
+
1) launch "$(printf '%s' "$rows" | cut -f1)" ;;
|
|
74
|
+
esac
|
|
75
|
+
|
|
76
|
+
command -v fzf >/dev/null 2>&1 || {
|
|
77
|
+
echo "fzf is not installed, so there is no menu. Naming a profile still works:"
|
|
78
|
+
printf '%s\n' "$rows" | cut -f1 | sed 's/.*/ acstack-db "&"/'
|
|
79
|
+
exit 1; }
|
|
80
|
+
|
|
81
|
+
choice=$(printf '%s\n' "$rows" \
|
|
82
|
+
| fzf --delimiter=$'\t' --with-nth=2.. --no-multi --cycle \
|
|
83
|
+
--prompt="db > " --header="harlequin · profile adapter" \
|
|
84
|
+
| cut -f1)
|
|
85
|
+
[ -n "$choice" ] || exit 0
|
|
86
|
+
launch "$choice"
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Show a git diff in diffnav, with a banner naming what is being compared.
|
|
3
|
+
#
|
|
4
|
+
# acstack-diff this branch against dev, committed work only (alt+f)
|
|
5
|
+
# acstack-diff pick choose the comparison from a menu (alt+x)
|
|
6
|
+
# acstack-diff auto first non-empty of working tree, staged, last commit
|
|
7
|
+
#
|
|
8
|
+
# Every launch prepends a COMPARING block to the diff. diffnav renders whatever
|
|
9
|
+
# precedes the first "diff --git" line, which is how git show's commit header
|
|
10
|
+
# reaches the screen, so the block lands at the top of the content pane.
|
|
11
|
+
#
|
|
12
|
+
# diffnav's "o" key opens the file under the cursor with $EDITOR, in diffnav's
|
|
13
|
+
# own pane. That pane is a herdr popup for alt+f and alt+x, and a popup cannot
|
|
14
|
+
# host a second full-screen app, so the editor never draws there. acstack-edit
|
|
15
|
+
# gives the file its own herdr tab instead, and both diff keys then match.
|
|
16
|
+
set -uo pipefail
|
|
17
|
+
if command -v acstack-edit >/dev/null 2>&1; then
|
|
18
|
+
case "${EDITOR:-}" in *acstack-edit) EDITOR=nvim ;; esac # never nest
|
|
19
|
+
export ACSTACK_EDIT_EDITOR="${EDITOR:-nvim}"
|
|
20
|
+
export EDITOR=acstack-edit
|
|
21
|
+
fi
|
|
22
|
+
pick_checkout() {
|
|
23
|
+
# herdr groups worktrees as <root>/<repo>/<branch-slug>, so a pane often
|
|
24
|
+
# sits in the parent of several checkouts rather than in one.
|
|
25
|
+
local -a repos=()
|
|
26
|
+
while IFS= read -r d; do repos+=("$d"); done < <(
|
|
27
|
+
find . -mindepth 1 -maxdepth 1 -type d -exec test -e '{}/.git' \; -print 2>/dev/null | sort)
|
|
28
|
+
case ${#repos[@]} in
|
|
29
|
+
0) return 1 ;;
|
|
30
|
+
1) cd "${repos[0]}" && return 0 ;;
|
|
31
|
+
*) echo "Not a repo, but it holds ${#repos[@]} checkouts:"; echo
|
|
32
|
+
local i=1
|
|
33
|
+
for d in "${repos[@]}"; do
|
|
34
|
+
printf ' %d) %-34s %s\n' "$i" "${d#./}" "$(git -C "$d" branch --show-current 2>/dev/null)"
|
|
35
|
+
i=$((i+1))
|
|
36
|
+
done
|
|
37
|
+
echo; read -rp "Which one? [1-${#repos[@]}, q to quit] " choice
|
|
38
|
+
[[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le ${#repos[@]} ] \
|
|
39
|
+
&& cd "${repos[$((choice-1))]}" && return 0
|
|
40
|
+
return 1 ;;
|
|
41
|
+
esac
|
|
42
|
+
}
|
|
43
|
+
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
|
44
|
+
pick_checkout || { echo "No git checkout here: $PWD"; echo "Press any key..."; read -rsn1; exit 0; }
|
|
45
|
+
fi
|
|
46
|
+
|
|
47
|
+
BRANCH=$(git branch --show-current 2>/dev/null) || BRANCH=""
|
|
48
|
+
[ -n "$BRANCH" ] || BRANCH="HEAD (detached)"
|
|
49
|
+
|
|
50
|
+
# ---- refs -----------------------------------------------------------------
|
|
51
|
+
ref_exists() { git rev-parse --verify --quiet "$1" >/dev/null 2>&1; }
|
|
52
|
+
first_ref() { local r; for r in "$@"; do ref_exists "$r" && { echo "$r"; return 0; }; done; return 1; }
|
|
53
|
+
dev_ref() { first_ref origin/dev dev; } # the server's dev wins over a stale local one
|
|
54
|
+
# default_compare falls back to the default branch only where no dev branch exists
|
|
55
|
+
default_ref() {
|
|
56
|
+
local d; d=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
|
|
57
|
+
[ -n "$d" ] && { echo "$d"; return 0; }
|
|
58
|
+
first_ref origin/main main origin/master master
|
|
59
|
+
}
|
|
60
|
+
tip() { # tip <ref> -> "1b298bd . 4 hours ago . Merge pull request ..."
|
|
61
|
+
# kept short: diffnav's content pane is narrower than the popup, so a long
|
|
62
|
+
# line wraps and the banner stops looking like a banner.
|
|
63
|
+
git log -1 --format='%h %ar %s' "$1" 2>/dev/null | cut -c1-70
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
# ---- what diffnav cannot draw ---------------------------------------------
|
|
67
|
+
# diffnav hands each file to delta, and delta dies on SIGABRT when one line
|
|
68
|
+
# runs to hundreds of kilobytes. diffnav reports the child's death as
|
|
69
|
+
# "FATA signal: aborted (core dumped)" and quits, so one such file takes the
|
|
70
|
+
# whole diff down with it. A committed build artifact is what does it: this
|
|
71
|
+
# project's own cli/dist/index.js.map holds a single 982,428-character line,
|
|
72
|
+
# and the merge commit touching it aborted diffnav in 1.7 seconds.
|
|
73
|
+
#
|
|
74
|
+
# So the file goes, not the diff. Any file whose patch holds a line over the
|
|
75
|
+
# limit is dropped and named in the banner, and the rest is drawn as usual.
|
|
76
|
+
# 20000 is far above any line a person writes and far below where delta gives
|
|
77
|
+
# out. Raise or lower it with ACSTACK_DIFF_LINE_LIMIT.
|
|
78
|
+
LINE_LIMIT_DEFAULT=20000
|
|
79
|
+
LINE_LIMIT="${ACSTACK_DIFF_LINE_LIMIT:-$LINE_LIMIT_DEFAULT}"
|
|
80
|
+
# awk compares a length against whatever this holds. Hand it "abc" and every
|
|
81
|
+
# comparison is a string comparison that is never true, so the guard turns
|
|
82
|
+
# itself off and the abort comes back with nothing on screen to explain it.
|
|
83
|
+
# Hand it 0 and every file is dropped instead. Refuse both, out loud.
|
|
84
|
+
case "$LINE_LIMIT" in
|
|
85
|
+
''|0|*[!0-9]*)
|
|
86
|
+
echo "acstack-diff: ACSTACK_DIFF_LINE_LIMIT is \"$LINE_LIMIT\", which is not a" >&2
|
|
87
|
+
echo " positive whole number. Using $LINE_LIMIT_DEFAULT." >&2
|
|
88
|
+
LINE_LIMIT=$LINE_LIMIT_DEFAULT ;;
|
|
89
|
+
esac
|
|
90
|
+
drop_unrenderable() { # drop_unrenderable <skipped-file> ; diff in, diff out
|
|
91
|
+
# One file at a time: a patch has to be held whole before its longest line
|
|
92
|
+
# is known. Everything before the first "diff --git" is a header, so it goes
|
|
93
|
+
# straight through. Written for gawk, mawk and busybox awk alike.
|
|
94
|
+
awk -v lim="$LINE_LIMIT" -v skip="$1" '
|
|
95
|
+
function flush() {
|
|
96
|
+
if (path == "") return
|
|
97
|
+
# A tab between the two fields. banner reads this file back and splits
|
|
98
|
+
# on the same tab, so the two have to move together.
|
|
99
|
+
if (long) printf "%s\t%d\n", path, maxlen > skip
|
|
100
|
+
else for (i = 1; i <= n; i++) print buf[i]
|
|
101
|
+
}
|
|
102
|
+
/^diff --git / {
|
|
103
|
+
flush()
|
|
104
|
+
split("", buf); n = 0; long = 0; maxlen = 0
|
|
105
|
+
path = $0; sub(/^diff --git a\//, "", path); sub(/ b\/.*$/, "", path)
|
|
106
|
+
if (path == "") path = "?"
|
|
107
|
+
}
|
|
108
|
+
{
|
|
109
|
+
if (path == "") { print; next }
|
|
110
|
+
if (length($0) > maxlen) maxlen = length($0)
|
|
111
|
+
if (length($0) > lim) long = 1
|
|
112
|
+
buf[++n] = $0
|
|
113
|
+
}
|
|
114
|
+
END { flush() }'
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
# ---- banner ---------------------------------------------------------------
|
|
118
|
+
banner() { # banner <source-label> <change-label> <what> <command>
|
|
119
|
+
# No line may start with a space: diffnav strips leading whitespace, so any
|
|
120
|
+
# indentation collapses and columns stop lining up.
|
|
121
|
+
printf 'COMPARING %s ==> %s\n' "$1" "$2"
|
|
122
|
+
# base and compare repeat their ref so each row ties back to the first line.
|
|
123
|
+
[ -n "${SRC_TIP:-}" ] && printf 'base %s\n' "$(printf '%s %s' "$1" "$SRC_TIP" | cut -c1-70)"
|
|
124
|
+
[ -n "${CHG_TIP:-}" ] && printf 'compare %s\n' "$(printf '%s %s' "$2" "$CHG_TIP" | cut -c1-70)"
|
|
125
|
+
printf 'showing %s\n' "$3"
|
|
126
|
+
printf 'command %s\n' "$4"
|
|
127
|
+
# A dropped file is named here rather than left to be noticed as missing.
|
|
128
|
+
# The tab is what drop_unrenderable writes between the two fields.
|
|
129
|
+
[ -s "${SKIP:-}" ] && while IFS="$(printf '\t')" read -r p len; do
|
|
130
|
+
printf 'skipped %s (one line is %s chars, and diffnav aborts on it)\n' "$p" "$len"
|
|
131
|
+
done < "$SKIP"
|
|
132
|
+
return 0
|
|
133
|
+
}
|
|
134
|
+
launch() { # launch <source> <change> <what> <git-args...>
|
|
135
|
+
local src="$1" chg="$2" what="$3"; shift 3
|
|
136
|
+
local cmd="git $*"
|
|
137
|
+
local tmp; tmp=$(mktemp); SKIP=$(mktemp)
|
|
138
|
+
git "$@" 2>/dev/null | drop_unrenderable "$SKIP" > "$tmp"
|
|
139
|
+
if grep -q '^diff --git' "$tmp"; then
|
|
140
|
+
# diffnav puts the line after "commit <sha>" in its status bar, and the bar
|
|
141
|
+
# stays put while you walk the file tree. A git show stream carries its own
|
|
142
|
+
# header, so only a plain diff needs one synthesised.
|
|
143
|
+
{ [ "${1:-}" = diff ] && printf 'commit %s\n' "$(git rev-parse HEAD 2>/dev/null)"
|
|
144
|
+
banner "$src" "$chg" "$what" "$cmd"; echo; cat "$tmp"; } | diffnav
|
|
145
|
+
else
|
|
146
|
+
# Nothing for diffnav to draw, for one of two different reasons. The
|
|
147
|
+
# banner and the wait for a keypress are the same either way, so only the
|
|
148
|
+
# explanation sits inside the branch.
|
|
149
|
+
banner "$src" "$chg" "$what" "$cmd"
|
|
150
|
+
echo
|
|
151
|
+
if [ -s "$SKIP" ]; then
|
|
152
|
+
echo " Every file that changed carries a line too long for diffnav."
|
|
153
|
+
echo " They are named above, and nothing else differs between these two."
|
|
154
|
+
else
|
|
155
|
+
echo " No difference between these two. Nothing to show."
|
|
156
|
+
local dirty; dirty=$(git status --porcelain 2>/dev/null | grep -cv '^??')
|
|
157
|
+
if [ "${dirty:-0}" -gt 0 ]; then
|
|
158
|
+
echo " You do have $dirty file(s) changed but not committed."
|
|
159
|
+
echo " Press alt+x and pick \"my uncommitted changes\" to see them."
|
|
160
|
+
else
|
|
161
|
+
echo " Press alt+x to compare something else."
|
|
162
|
+
fi
|
|
163
|
+
fi
|
|
164
|
+
echo; echo "Press any key..."; read -rsn1
|
|
165
|
+
fi
|
|
166
|
+
rm -f "$tmp" "$SKIP"
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
# ---- comparisons ----------------------------------------------------------
|
|
170
|
+
against() { # against <base-ref> <committed|everything>
|
|
171
|
+
local base="$1" mode="$2" mb
|
|
172
|
+
SRC_TIP=$(tip "$base")
|
|
173
|
+
mb=$(git merge-base "$base" HEAD 2>/dev/null); mb=${mb:0:9}
|
|
174
|
+
[ -n "$mb" ] || { echo "No common history between $base and $BRANCH."
|
|
175
|
+
echo; echo "Press any key..."; read -rsn1; return 1; }
|
|
176
|
+
if [ "$mode" = committed ]; then
|
|
177
|
+
CHG_TIP=$(tip HEAD)
|
|
178
|
+
launch "$base" "$BRANCH" \
|
|
179
|
+
"commits on $BRANCH that $base does not have" \
|
|
180
|
+
diff "$base...HEAD"
|
|
181
|
+
else
|
|
182
|
+
CHG_TIP="the files as they sit on disk right now"
|
|
183
|
+
launch "$base" "$BRANCH plus my uncommitted changes" \
|
|
184
|
+
"everything $BRANCH has since it left $base, committed or not" \
|
|
185
|
+
diff "$mb"
|
|
186
|
+
fi
|
|
187
|
+
}
|
|
188
|
+
local_only() {
|
|
189
|
+
SRC_TIP=$(tip HEAD); CHG_TIP="the files as they sit on disk right now"
|
|
190
|
+
launch "the newest commit on $BRANCH" "my uncommitted changes" \
|
|
191
|
+
"edits I have made but not committed, staged or not" diff HEAD
|
|
192
|
+
}
|
|
193
|
+
one_commit() { # one_commit <sha>
|
|
194
|
+
SRC_TIP=$(tip "$1^" 2>/dev/null); CHG_TIP=$(tip "$1")
|
|
195
|
+
launch "the commit just before $1" "commit $1" "what this one commit changed" \
|
|
196
|
+
show "$1" -m --first-parent
|
|
197
|
+
}
|
|
198
|
+
auto() { # fallback when there is no branch comparison to make; $1 says why
|
|
199
|
+
local why="${1:-nothing new to compare against another branch}"
|
|
200
|
+
if ! git diff --quiet || ! git diff --cached --quiet; then local_only
|
|
201
|
+
else SRC_TIP=$(tip HEAD^ 2>/dev/null); CHG_TIP=$(tip HEAD)
|
|
202
|
+
launch "$BRANCH one commit back" "$BRANCH at its newest commit" \
|
|
203
|
+
"$why, and nothing is uncommitted, so here is that commit alone" \
|
|
204
|
+
show HEAD -m --first-parent; fi
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
# ---- picker ---------------------------------------------------------------
|
|
208
|
+
strip_ansi() { sed 's/\x1b\[[0-9;]*m//g'; }
|
|
209
|
+
pick_ref() { # pick_ref <prompt> -> a branch name
|
|
210
|
+
git for-each-ref --sort=-committerdate --format='%(refname:short)' \
|
|
211
|
+
refs/heads refs/remotes 2>/dev/null | grep -v '^origin/HEAD$' \
|
|
212
|
+
| fzf --prompt="$1 > " --no-multi --cycle --preview-window='right,60%' \
|
|
213
|
+
--preview='git log --oneline --decorate --color=always -20 {}'
|
|
214
|
+
}
|
|
215
|
+
pick_commit() { # pick_commit -> a short sha
|
|
216
|
+
git log --oneline --decorate --color=always -300 2>/dev/null \
|
|
217
|
+
| fzf --ansi --prompt="commit > " --no-multi --cycle --preview-window='right,60%' \
|
|
218
|
+
--preview='git show --stat --color=always {1}' \
|
|
219
|
+
| strip_ansi | awk '{print $1}'
|
|
220
|
+
}
|
|
221
|
+
pick() {
|
|
222
|
+
command -v fzf >/dev/null 2>&1 || { echo "fzf is not installed."; echo; auto; return; }
|
|
223
|
+
local dev def choice a b
|
|
224
|
+
dev=$(dev_ref); def=$(default_ref)
|
|
225
|
+
local -a rows=()
|
|
226
|
+
row() { rows+=("$(printf '%s\t%-24s ==> %-30s %s' "$1" "$2" "$3" "$4")"); }
|
|
227
|
+
[ -n "$dev" ] && [ "$dev" != "$BRANCH" ] && {
|
|
228
|
+
row dev-committed "$dev" "$BRANCH" "committed work only"
|
|
229
|
+
row dev-everything "$dev" "$BRANCH + uncommitted" "committed work plus what is not committed"; }
|
|
230
|
+
[ -n "$def" ] && [ "$def" != "$BRANCH" ] && [ "$def" != "$dev" ] && {
|
|
231
|
+
row def-committed "$def" "$BRANCH" "committed work only"
|
|
232
|
+
row def-everything "$def" "$BRANCH + uncommitted" "committed work plus what is not committed"; }
|
|
233
|
+
row local "HEAD" "my uncommitted work" "staged and unstaged changes only"
|
|
234
|
+
row commit "its parent" "one commit" "pick a commit from this branch's history"
|
|
235
|
+
row branch "any branch" "any branch" "pick the source, then the branch to compare"
|
|
236
|
+
choice=$(printf '%s\n' "${rows[@]}" \
|
|
237
|
+
| fzf --delimiter=$'\t' --with-nth=2.. --no-multi --cycle \
|
|
238
|
+
--prompt="diff > " --header="on $BRANCH · source ==> change" \
|
|
239
|
+
| cut -f1)
|
|
240
|
+
case "$choice" in
|
|
241
|
+
dev-committed) against "$dev" committed ;;
|
|
242
|
+
dev-everything) against "$dev" everything ;;
|
|
243
|
+
def-committed) against "$def" committed ;;
|
|
244
|
+
def-everything) against "$def" everything ;;
|
|
245
|
+
local) local_only ;;
|
|
246
|
+
commit) a=$(pick_commit); [ -n "$a" ] && one_commit "$a" ;;
|
|
247
|
+
branch) a=$(pick_ref "source (compare against)") ; [ -z "$a" ] && exit 0
|
|
248
|
+
b=$(pick_ref "change (the new work)") ; [ -z "$b" ] && exit 0
|
|
249
|
+
SRC_TIP=$(tip "$a"); CHG_TIP=$(tip "$b")
|
|
250
|
+
launch "$a" "$b" "commits on $b that $a does not have" diff "$a...$b" ;;
|
|
251
|
+
*) exit 0 ;;
|
|
252
|
+
esac
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
# ---- entry ----------------------------------------------------------------
|
|
256
|
+
default_compare() { # alt+f: the newest commit on this branch, against origin/dev
|
|
257
|
+
local base; base=$(dev_ref) || base=$(default_ref)
|
|
258
|
+
# Only when this checkout IS the base branch is there nothing to compare.
|
|
259
|
+
if [ -z "$base" ] || [ "$base" = "$BRANCH" ] || [ "origin/$BRANCH" = "$base" ]; then
|
|
260
|
+
auto "$BRANCH is the branch everything else compares against"; return; fi
|
|
261
|
+
against "$base" committed
|
|
262
|
+
}
|
|
263
|
+
case "${1:-}" in
|
|
264
|
+
"") default_compare ;;
|
|
265
|
+
pick) pick ;;
|
|
266
|
+
auto) auto ;;
|
|
267
|
+
*) echo "usage: acstack-diff [pick|auto]"; exit 2 ;;
|
|
268
|
+
esac
|