acstack 0.0.0-stage → 0.47.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 +19 -0
- package/LICENSE +21 -0
- package/README.md +389 -2
- package/THIRD_PARTY_NOTICES +14 -0
- package/TROUBLESHOOTING.md +225 -0
- package/agents/codex/frontend-verifier.toml +352 -0
- package/agents/codex/task-checker.toml +345 -0
- package/agents/codex/task-executor.toml +157 -0
- package/agents/codex/test-writer.toml +149 -0
- package/agents/frontend-verifier.md +351 -0
- package/agents/task-checker.md +344 -0
- package/agents/task-executor.md +156 -0
- package/agents/test-writer.md +148 -0
- package/assets/playwriter-bridge/build-launcher.ps1 +32 -0
- package/assets/playwriter-bridge/doctor.ps1 +450 -0
- package/assets/playwriter-bridge/launcher-src.cs +131 -0
- package/assets/playwriter-bridge/opener.js +330 -0
- package/assets/playwriter-bridge/opener.ps1 +63 -0
- package/assets/playwriter-bridge/register-tasks.ps1 +328 -0
- package/assets/playwriter-bridge/relay.ps1 +50 -0
- package/assets/playwriter-bridge/stop.ps1 +125 -0
- package/assets/playwriter-bridge/sync.ps1 +271 -0
- package/assets/playwriter-bridge/tunnel.ps1 +325 -0
- package/assets/playwriter-bridge/vm-free-port.sh +166 -0
- package/assets/terminal-workspace/client-keybindings.md +87 -0
- package/assets/terminal-workspace/config.example.yaml +334 -0
- package/assets/terminal-workspace/setup.sh +4404 -0
- package/cli/README.md +47 -0
- package/cli/bin/acstack.js +3 -0
- package/cli/dist/index.js +25984 -0
- package/cli/dist/index.js.map +7 -0
- package/cli/package.json +59 -0
- package/docs/adr/0001-ste-writing-standard.md +16 -0
- package/docs/adr/0002-version-gate-not-auto-bump.md +35 -0
- package/docs/adr/0003-handoff-ack-command.md +19 -0
- package/docs/adr/0004-dev-setup-per-host.md +13 -0
- package/docs/adr/0005-two-orchestrators-per-host.md +25 -0
- package/docs/adr/0006-document-opener-service.md +89 -0
- package/docs/adr/0007-lead-check-reads-the-transcript.md +87 -0
- package/docs/adr/0008-cli-audits-skill-interviews.md +37 -0
- package/docs/adr/0009-verify-the-write-not-the-writer.md +51 -0
- package/docs/adr/0010-promote-dev-to-main-by-fast-forward.md +115 -0
- package/docs/adr/0011-node-header-is-the-edge-encoding.md +20 -0
- package/docs/adr/0012-node-kind-is-a-closed-set-of-five.md +31 -0
- package/docs/adr/0013-the-diagram-renderer-is-pure.md +23 -0
- package/docs/adr/0014-reading-a-node-hides-its-generated-diagram.md +29 -0
- package/docs/adr/0015-a-node-id-links-to-its-issue-through-an-anchor.md +37 -0
- package/docs/adr/0016-the-map-root-carries-no-status-of-its-own.md +21 -0
- package/docs/adr/0017-call-impeccable-copy-only-its-questions.md +13 -0
- package/docs/adr/index.md +17 -0
- package/docs/config-reference.md +572 -0
- package/docs/dev-setups.md +164 -0
- package/docs/migrate-0.8.md +28 -0
- package/docs/playwriter-bridge-windows.md +15 -0
- package/docs/terminal-workspace-keys.md +137 -0
- package/docs/terminal-workspace.md +1780 -0
- package/docs/workflows.md +309 -0
- package/hooks/ac-writing-style-reminder.sh +86 -0
- package/hooks/context-pressure.sh +583 -0
- package/hooks/hooks.json +50 -0
- package/hooks/session-start-handoff.sh +165 -0
- package/hooks/session-start.sh +214 -0
- package/orchestrator/AGENTS.md +532 -0
- package/output-styles/ac-writing-style.md +286 -0
- package/package.json +50 -4
- package/pi/agents/frontend-verifier.md +357 -0
- package/pi/agents/task-checker.md +348 -0
- package/pi/agents/task-executor.md +160 -0
- package/pi/agents/test-writer.md +154 -0
- package/pi/extension.ts +723 -0
- package/profiles/fullstack-python.yaml +29 -0
- package/profiles/node-typescript.yaml +23 -0
- package/profiles/python.yaml +24 -0
- package/skills/archive/SKILL.md +160 -0
- package/skills/bootstrap/SKILL.md +109 -0
- package/skills/bridge/HANDOFF.md +93 -0
- package/skills/bridge/SKILL-VM.md +292 -0
- package/skills/bridge/SKILL-WINDOWS.md +526 -0
- package/skills/bridge/SKILL.md +121 -0
- package/skills/browser-verify/SKILL.md +229 -0
- package/skills/code-review/SKILL.md +44 -0
- package/skills/commit/SKILL.md +106 -0
- package/skills/compact-and-continue/SKILL.md +241 -0
- package/skills/explore/SKILL.md +149 -0
- package/skills/grilling/SKILL.md +134 -0
- package/skills/handoff/SKILL.md +250 -0
- package/skills/handoff/launch-herdr.md +197 -0
- package/skills/handoff/launch-without-herdr.md +30 -0
- package/skills/handoff/lead-check.md +59 -0
- package/skills/handoff/outcomes.md +162 -0
- package/skills/handoff/running-agent.md +50 -0
- package/skills/handoff/watch.md +115 -0
- package/skills/host/SKILL.md +247 -0
- package/skills/host/detect-host.sh +297 -0
- package/skills/host/herdr.md +79 -0
- package/skills/host/install-herdr-block.sh +467 -0
- package/skills/host/install-orca.sh +512 -0
- package/skills/host/notes.md +97 -0
- package/skills/host/orca.md +160 -0
- package/skills/host/questions.md +163 -0
- package/skills/host/report.md +42 -0
- package/skills/implement/SKILL.md +230 -0
- package/skills/implement/decision-log.md +62 -0
- package/skills/implement/design-pass.md +32 -0
- package/skills/implement/relaxed-tdd.md +28 -0
- package/skills/map/SKILL.md +250 -0
- package/skills/map/frontend.md +227 -0
- package/skills/map/trackers/files.md +74 -0
- package/skills/map/trackers/github.md +180 -0
- package/skills/map/visuals.md +387 -0
- package/skills/new-worktree/SKILL.md +155 -0
- package/skills/new-worktree/detect-orchestrator.sh +328 -0
- package/skills/new-worktree/detect-orchestrator.test.sh +894 -0
- package/skills/new-worktree/herdr.md +84 -0
- package/skills/new-worktree/orca.md +101 -0
- package/skills/pre-commit-review/SKILL.md +242 -0
- package/skills/pre-commit-review/auto-fix.md +19 -0
- package/skills/pre-commit-review/interactive.md +35 -0
- package/skills/quick-fix/SKILL.md +125 -0
- package/skills/record-context/SKILL.md +106 -0
- package/skills/setup/SKILL.md +250 -0
- package/skills/setup/browser.md +123 -0
- package/skills/setup/design-review.md +97 -0
- package/skills/setup/first-run.md +114 -0
- package/skills/setup/report.md +68 -0
- package/skills/setup/row-fixes.md +196 -0
- package/skills/setup/terminal-workspace.md +153 -0
- package/skills/sync/SKILL.md +171 -0
- package/skills/teardown-worktree/SKILL.md +245 -0
- package/skills/teardown-worktree/detector.md +39 -0
- package/skills/teardown-worktree/herdr.md +113 -0
- package/skills/teardown-worktree/orca.md +61 -0
- package/skills/terminal-workspace/SKILL.md +249 -0
- package/skills/terminal-workspace/components.md +36 -0
- package/skills/terminal-workspace/config-choices.md +69 -0
- package/skills/terminal-workspace/emulator-keys.md +48 -0
- package/skills/terminal-workspace/headless-clipboard.md +175 -0
- package/skills/terminal-workspace/turn-off-and-update.md +104 -0
- package/skills/test-conventions/SKILL.md +59 -0
- package/skills/visual-docs/SKILL.md +246 -0
- package/skills/writing/SKILL.md +231 -0
- package/skills/writing/examples.md +153 -0
- package/skills/writing/vocabulary.md +96 -0
|
@@ -0,0 +1,4404 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# ACStack terminal workspace setup.
|
|
3
|
+
# setup.sh status what is installed and enabled
|
|
4
|
+
# setup.sh apply install and configure everything enabled in the config
|
|
5
|
+
# setup.sh outdated what has a newer version, and what components are new
|
|
6
|
+
# setup.sh upgrade reinstall whatever outdated reported
|
|
7
|
+
# setup.sh disable <herdr|delta|diffnav|gh_dash|harlequin|lazygit|neovim|pi|tuicr>
|
|
8
|
+
# setup.sh uninstall remove every managed block, keep the binaries
|
|
9
|
+
#
|
|
10
|
+
# Idempotent. Only ever edits between managed markers, so hand-written config
|
|
11
|
+
# around it survives.
|
|
12
|
+
set -uo pipefail
|
|
13
|
+
|
|
14
|
+
CFG="${ACSTACK_TW_CONFIG:-$HOME/.config/acstack/terminal-workspace.yaml}"
|
|
15
|
+
BIN="${ACSTACK_TW_BIN:-$HOME/.local/bin}"
|
|
16
|
+
# Everything installs into $BIN, and the config steps then ask `have <tool>`
|
|
17
|
+
# whether it worked. On a fresh machine $BIN is not on PATH yet, so without
|
|
18
|
+
# this every tool installs and every config step is skipped.
|
|
19
|
+
# herdr's own installer always targets ~/.local/bin, which is only the same
|
|
20
|
+
# as $BIN when ACSTACK_TW_BIN is left alone. Cover both.
|
|
21
|
+
PATH="$BIN:$HOME/.local/bin:$PATH"
|
|
22
|
+
HERDR_CFG="$HOME/.config/herdr/config.toml"
|
|
23
|
+
GHDASH_CFG="$HOME/.config/gh-dash/config.yml"
|
|
24
|
+
# LazyVim loads every file under lua/plugins, so a file of our own is a
|
|
25
|
+
# complete plugin spec. Nothing of the user's is edited to install it.
|
|
26
|
+
NVIM_PLUGIN="$HOME/.config/nvim/lua/plugins/acstack.lua"
|
|
27
|
+
NVIM_MARK="Written by acstack terminal-workspace"
|
|
28
|
+
BEGIN="# >>> acstack terminal-workspace >>>"
|
|
29
|
+
END="# <<< acstack terminal-workspace <<<"
|
|
30
|
+
# pi keeps every config file under one agent directory, and honours an
|
|
31
|
+
# override for it.
|
|
32
|
+
PI_DIR="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
|
|
33
|
+
PI_SETTINGS="$PI_DIR/settings.json"
|
|
34
|
+
PI_KEYBINDINGS="$PI_DIR/keybindings.json"
|
|
35
|
+
PI_SUBAGENT_CFG="$PI_DIR/extensions/subagent/config.json"
|
|
36
|
+
# agentic-retro install-pi picks its own directory, from $PI_HOME rather than
|
|
37
|
+
# pi's override, so look where it writes.
|
|
38
|
+
PI_RETRO_EXT="${PI_HOME:-$HOME/.pi}/agent/extensions/agentic-retro.ts"
|
|
39
|
+
PI_ACSTACK="npm:acstack"
|
|
40
|
+
PI_BILLION="npm:billion-context"
|
|
41
|
+
PI_SLASH_ANYWHERE="npm:pi-slash-anywhere"
|
|
42
|
+
# A line starting with # is a heading in markdown, and the model reads it. An
|
|
43
|
+
# HTML comment carries the same marker and renders as nothing.
|
|
44
|
+
PI_MD_BEGIN="<!-- >>> acstack terminal-workspace >>> -->"
|
|
45
|
+
PI_MD_END="<!-- <<< acstack terminal-workspace <<< -->"
|
|
46
|
+
PI_DELEGATION="When an ACStack skill or the ACStack orchestrator says to launch a subagent, you may delegate to it."
|
|
47
|
+
ACTION="${1:-status}"
|
|
48
|
+
|
|
49
|
+
have() { command -v "$1" >/dev/null 2>&1; }
|
|
50
|
+
|
|
51
|
+
require_yaml() {
|
|
52
|
+
python3 -c 'import yaml' 2>/dev/null && return 0
|
|
53
|
+
echo "PyYAML is required to read $CFG" >&2
|
|
54
|
+
echo "Install it (pip install --user pyyaml) and run again. Refusing to" >&2
|
|
55
|
+
echo "continue, because without it every setting falls back to a default" >&2
|
|
56
|
+
echo "and your config is silently ignored." >&2
|
|
57
|
+
exit 1
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
arch_supported() {
|
|
61
|
+
case "$(uname -m)" in
|
|
62
|
+
x86_64|amd64) return 0 ;;
|
|
63
|
+
*) say "prebuilt binaries are x86_64 only, and this is $(uname -m)"
|
|
64
|
+
say "install herdr, delta, diffnav, and lazygit yourself, then run apply again"
|
|
65
|
+
return 1 ;;
|
|
66
|
+
esac
|
|
67
|
+
}
|
|
68
|
+
say() { printf ' %s\n' "$*"; }
|
|
69
|
+
|
|
70
|
+
py() { python3 - "$@"; }
|
|
71
|
+
|
|
72
|
+
# Reads one dotted path. require_yaml runs first, so a fallback here means
|
|
73
|
+
# the key is absent, never that yaml failed to import.
|
|
74
|
+
cfg_get() { # cfg_get <dotted.path> <default>
|
|
75
|
+
ACSTACK_CFG="$CFG" py "$1" "${2:-}" <<'PY'
|
|
76
|
+
import os, sys
|
|
77
|
+
import yaml
|
|
78
|
+
p = os.environ["ACSTACK_CFG"]
|
|
79
|
+
if not os.path.isfile(p):
|
|
80
|
+
print(sys.argv[2]); raise SystemExit
|
|
81
|
+
d = yaml.safe_load(open(p)) or {}
|
|
82
|
+
for k in sys.argv[1].split("."):
|
|
83
|
+
d = d.get(k) if isinstance(d, dict) else None
|
|
84
|
+
if d is None: break
|
|
85
|
+
if isinstance(d, bool): d = "true" if d else "false"
|
|
86
|
+
print(d if d is not None else sys.argv[2])
|
|
87
|
+
PY
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
# neovim and pi write into a config the user built, so they start off. Every
|
|
91
|
+
# other component owns its files outright and starts on.
|
|
92
|
+
enabled_default() { # enabled_default <component>
|
|
93
|
+
case "$1" in neovim|pi) echo false ;; *) echo true ;; esac
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
# The example config that shipped beside this script. Both `outdated` and the
|
|
97
|
+
# stamp read it, so resolve it once.
|
|
98
|
+
EXAMPLE="$(dirname "$0")/config.example.yaml"
|
|
99
|
+
STAMP="$(dirname "$CFG")/terminal-workspace.applied"
|
|
100
|
+
# What the pi component changed, so disable undoes that and nothing else. A
|
|
101
|
+
# package or a setting the user already had is theirs, and stays.
|
|
102
|
+
PI_OWNED="$(dirname "$CFG")/terminal-workspace.pi.json"
|
|
103
|
+
|
|
104
|
+
# Reads a yaml list into one item per line. cfg_get prints a python list repr
|
|
105
|
+
# for a list value, which no caller can use.
|
|
106
|
+
cfg_get_list() { # cfg_get_list <dotted.path> <space-separated default>
|
|
107
|
+
ACSTACK_CFG="$CFG" py "$1" "${2:-}" <<'PY'
|
|
108
|
+
import os, sys
|
|
109
|
+
import yaml
|
|
110
|
+
p = os.environ["ACSTACK_CFG"]
|
|
111
|
+
d = (yaml.safe_load(open(p)) or {}) if os.path.isfile(p) else {}
|
|
112
|
+
for k in sys.argv[1].split("."):
|
|
113
|
+
d = d.get(k) if isinstance(d, dict) else None
|
|
114
|
+
if d is None: break
|
|
115
|
+
print("\n".join(str(x) for x in d) if isinstance(d, list) else sys.argv[2].replace(" ", "\n"))
|
|
116
|
+
PY
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# What `apply` had to offer, recorded so a later run can tell what arrived
|
|
120
|
+
# since. Without it, a component added by an upgrade is invisible: cfg_get
|
|
121
|
+
# returns its default, apply installs it, and nobody is ever told it is there.
|
|
122
|
+
write_stamp() {
|
|
123
|
+
[ -f "$EXAMPLE" ] || return 0
|
|
124
|
+
mkdir -p "$(dirname "$STAMP")"
|
|
125
|
+
{ echo "# written by acstack terminal-workspace apply, do not edit"
|
|
126
|
+
py "$EXAMPLE" <<'PY'
|
|
127
|
+
import sys
|
|
128
|
+
import yaml
|
|
129
|
+
for k in (yaml.safe_load(open(sys.argv[1])) or {}):
|
|
130
|
+
print(k)
|
|
131
|
+
PY
|
|
132
|
+
} > "$STAMP"
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
install_binary() { # install_binary <name> <repo> <asset-match>
|
|
136
|
+
local name="$1" repo="$2" match="$3"
|
|
137
|
+
# `upgrade` reinstalls over a binary that is already here. `apply` never
|
|
138
|
+
# does: it installs what is missing and leaves what is not.
|
|
139
|
+
if [ "${INSTALL_FORCE:-0}" != "1" ] && have "$name"; then
|
|
140
|
+
say "$name already installed"; return 0
|
|
141
|
+
fi
|
|
142
|
+
local url json
|
|
143
|
+
# py reads its script from stdin, so the release JSON has to arrive by
|
|
144
|
+
# environment rather than by pipe: a pipe here is silently swallowed by
|
|
145
|
+
# the heredoc and python then reads an empty stdin.
|
|
146
|
+
json=$(curl -sSf "https://api.github.com/repos/$repo/releases/latest" 2>/dev/null)
|
|
147
|
+
[ -z "$json" ] && { say "$name: could not reach the GitHub release API"; return 1; }
|
|
148
|
+
url=$(ACSTACK_JSON="$json" py "$match" <<'PY'
|
|
149
|
+
import json, os, sys
|
|
150
|
+
try:
|
|
151
|
+
d = json.loads(os.environ["ACSTACK_JSON"])
|
|
152
|
+
except ValueError:
|
|
153
|
+
raise SystemExit
|
|
154
|
+
for a in d.get("assets", []):
|
|
155
|
+
# yazi publishes .zip only; delta and glow publish .tar.gz. Take either.
|
|
156
|
+
if sys.argv[1] in a["name"] and a["name"].endswith((".tar.gz", ".tgz", ".zip")):
|
|
157
|
+
print(a["browser_download_url"]); break
|
|
158
|
+
PY
|
|
159
|
+
)
|
|
160
|
+
[ -z "$url" ] && { say "$name: no matching release asset, install manually"; return 1; }
|
|
161
|
+
# Explicit cleanup rather than `trap ... RETURN`: a RETURN trap stays
|
|
162
|
+
# registered for every later function return in this shell, where $tmp is
|
|
163
|
+
# gone and `set -u` then aborts the run.
|
|
164
|
+
local tmp found ar; tmp=$(mktemp -d)
|
|
165
|
+
case "$url" in *.zip) ar="$tmp/a.zip" ;; *) ar="$tmp/a.tgz" ;; esac
|
|
166
|
+
if ! curl -sL "$url" -o "$ar"; then
|
|
167
|
+
rm -rf "$tmp"; say "$name: download failed"; return 1
|
|
168
|
+
fi
|
|
169
|
+
case "$ar" in
|
|
170
|
+
*.zip) have unzip || { rm -rf "$tmp"; say "$name: needs unzip"; return 1; }
|
|
171
|
+
unzip -qo "$ar" -d "$tmp" || { rm -rf "$tmp"; say "$name: unzip failed"; return 1; } ;;
|
|
172
|
+
*) tar xzf "$ar" -C "$tmp" || { rm -rf "$tmp"; say "$name: untar failed"; return 1; } ;;
|
|
173
|
+
esac
|
|
174
|
+
found=$(find "$tmp" -type f -name "$name" | head -1)
|
|
175
|
+
if [ -z "$found" ]; then
|
|
176
|
+
rm -rf "$tmp"; say "$name: binary not found in archive"; return 1
|
|
177
|
+
fi
|
|
178
|
+
mkdir -p "$BIN"; cp "$found" "$BIN/$name"; chmod +x "$BIN/$name"
|
|
179
|
+
rm -rf "$tmp"
|
|
180
|
+
say "$name installed to $BIN/$name"
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
write_helpers() {
|
|
184
|
+
mkdir -p "$BIN"
|
|
185
|
+
# Helpers this script used to write and no longer does. Upgrading otherwise
|
|
186
|
+
# leaves them on PATH, shadowing nothing but confusing everything.
|
|
187
|
+
rm -f "$BIN"/acstack-files "$BIN"/acstack-files-tab \
|
|
188
|
+
"$BIN"/acstack-yazi-tab "$BIN"/acstack-tab "$BIN"/acstack-renumber
|
|
189
|
+
cat > "$BIN/acstack-diff" <<'H'
|
|
190
|
+
#!/usr/bin/env bash
|
|
191
|
+
# Show a git diff in diffnav, with a banner naming what is being compared.
|
|
192
|
+
#
|
|
193
|
+
# acstack-diff this branch against dev, committed work only (alt+f)
|
|
194
|
+
# acstack-diff pick choose the comparison from a menu (alt+x)
|
|
195
|
+
# acstack-diff auto first non-empty of working tree, staged, last commit
|
|
196
|
+
#
|
|
197
|
+
# Every launch prepends a COMPARING block to the diff. diffnav renders whatever
|
|
198
|
+
# precedes the first "diff --git" line, which is how git show's commit header
|
|
199
|
+
# reaches the screen, so the block lands at the top of the content pane.
|
|
200
|
+
#
|
|
201
|
+
# diffnav's "o" key opens the file under the cursor with $EDITOR, in diffnav's
|
|
202
|
+
# own pane. That pane is a herdr popup for alt+f and alt+x, and a popup cannot
|
|
203
|
+
# host a second full-screen app, so the editor never draws there. acstack-edit
|
|
204
|
+
# gives the file its own herdr tab instead, and both diff keys then match.
|
|
205
|
+
set -uo pipefail
|
|
206
|
+
if command -v acstack-edit >/dev/null 2>&1; then
|
|
207
|
+
case "${EDITOR:-}" in *acstack-edit) EDITOR=nvim ;; esac # never nest
|
|
208
|
+
export ACSTACK_EDIT_EDITOR="${EDITOR:-nvim}"
|
|
209
|
+
export EDITOR=acstack-edit
|
|
210
|
+
fi
|
|
211
|
+
pick_checkout() {
|
|
212
|
+
# herdr groups worktrees as <root>/<repo>/<branch-slug>, so a pane often
|
|
213
|
+
# sits in the parent of several checkouts rather than in one.
|
|
214
|
+
local -a repos=()
|
|
215
|
+
while IFS= read -r d; do repos+=("$d"); done < <(
|
|
216
|
+
find . -mindepth 1 -maxdepth 1 -type d -exec test -e '{}/.git' \; -print 2>/dev/null | sort)
|
|
217
|
+
case ${#repos[@]} in
|
|
218
|
+
0) return 1 ;;
|
|
219
|
+
1) cd "${repos[0]}" && return 0 ;;
|
|
220
|
+
*) echo "Not a repo, but it holds ${#repos[@]} checkouts:"; echo
|
|
221
|
+
local i=1
|
|
222
|
+
for d in "${repos[@]}"; do
|
|
223
|
+
printf ' %d) %-34s %s\n' "$i" "${d#./}" "$(git -C "$d" branch --show-current 2>/dev/null)"
|
|
224
|
+
i=$((i+1))
|
|
225
|
+
done
|
|
226
|
+
echo; read -rp "Which one? [1-${#repos[@]}, q to quit] " choice
|
|
227
|
+
[[ "$choice" =~ ^[0-9]+$ ]] && [ "$choice" -ge 1 ] && [ "$choice" -le ${#repos[@]} ] \
|
|
228
|
+
&& cd "${repos[$((choice-1))]}" && return 0
|
|
229
|
+
return 1 ;;
|
|
230
|
+
esac
|
|
231
|
+
}
|
|
232
|
+
if ! git rev-parse --git-dir >/dev/null 2>&1; then
|
|
233
|
+
pick_checkout || { echo "No git checkout here: $PWD"; echo "Press any key..."; read -rsn1; exit 0; }
|
|
234
|
+
fi
|
|
235
|
+
|
|
236
|
+
BRANCH=$(git branch --show-current 2>/dev/null) || BRANCH=""
|
|
237
|
+
[ -n "$BRANCH" ] || BRANCH="HEAD (detached)"
|
|
238
|
+
|
|
239
|
+
# ---- refs -----------------------------------------------------------------
|
|
240
|
+
ref_exists() { git rev-parse --verify --quiet "$1" >/dev/null 2>&1; }
|
|
241
|
+
first_ref() { local r; for r in "$@"; do ref_exists "$r" && { echo "$r"; return 0; }; done; return 1; }
|
|
242
|
+
dev_ref() { first_ref origin/dev dev; } # the server's dev wins over a stale local one
|
|
243
|
+
# default_compare falls back to the default branch only where no dev branch exists
|
|
244
|
+
default_ref() {
|
|
245
|
+
local d; d=$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)
|
|
246
|
+
[ -n "$d" ] && { echo "$d"; return 0; }
|
|
247
|
+
first_ref origin/main main origin/master master
|
|
248
|
+
}
|
|
249
|
+
tip() { # tip <ref> -> "1b298bd . 4 hours ago . Merge pull request ..."
|
|
250
|
+
# kept short: diffnav's content pane is narrower than the popup, so a long
|
|
251
|
+
# line wraps and the banner stops looking like a banner.
|
|
252
|
+
git log -1 --format='%h %ar %s' "$1" 2>/dev/null | cut -c1-70
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
# ---- what diffnav cannot draw ---------------------------------------------
|
|
256
|
+
# diffnav hands each file to delta, and delta dies on SIGABRT when one line
|
|
257
|
+
# runs to hundreds of kilobytes. diffnav reports the child's death as
|
|
258
|
+
# "FATA signal: aborted (core dumped)" and quits, so one such file takes the
|
|
259
|
+
# whole diff down with it. A committed build artifact is what does it: this
|
|
260
|
+
# project's own cli/dist/index.js.map holds a single 982,428-character line,
|
|
261
|
+
# and the merge commit touching it aborted diffnav in 1.7 seconds.
|
|
262
|
+
#
|
|
263
|
+
# So the file goes, not the diff. Any file whose patch holds a line over the
|
|
264
|
+
# limit is dropped and named in the banner, and the rest is drawn as usual.
|
|
265
|
+
# 20000 is far above any line a person writes and far below where delta gives
|
|
266
|
+
# out. Raise or lower it with ACSTACK_DIFF_LINE_LIMIT.
|
|
267
|
+
LINE_LIMIT_DEFAULT=20000
|
|
268
|
+
LINE_LIMIT="${ACSTACK_DIFF_LINE_LIMIT:-$LINE_LIMIT_DEFAULT}"
|
|
269
|
+
# awk compares a length against whatever this holds. Hand it "abc" and every
|
|
270
|
+
# comparison is a string comparison that is never true, so the guard turns
|
|
271
|
+
# itself off and the abort comes back with nothing on screen to explain it.
|
|
272
|
+
# Hand it 0 and every file is dropped instead. Refuse both, out loud.
|
|
273
|
+
case "$LINE_LIMIT" in
|
|
274
|
+
''|0|*[!0-9]*)
|
|
275
|
+
echo "acstack-diff: ACSTACK_DIFF_LINE_LIMIT is \"$LINE_LIMIT\", which is not a" >&2
|
|
276
|
+
echo " positive whole number. Using $LINE_LIMIT_DEFAULT." >&2
|
|
277
|
+
LINE_LIMIT=$LINE_LIMIT_DEFAULT ;;
|
|
278
|
+
esac
|
|
279
|
+
drop_unrenderable() { # drop_unrenderable <skipped-file> ; diff in, diff out
|
|
280
|
+
# One file at a time: a patch has to be held whole before its longest line
|
|
281
|
+
# is known. Everything before the first "diff --git" is a header, so it goes
|
|
282
|
+
# straight through. Written for gawk, mawk and busybox awk alike.
|
|
283
|
+
awk -v lim="$LINE_LIMIT" -v skip="$1" '
|
|
284
|
+
function flush() {
|
|
285
|
+
if (path == "") return
|
|
286
|
+
# A tab between the two fields. banner reads this file back and splits
|
|
287
|
+
# on the same tab, so the two have to move together.
|
|
288
|
+
if (long) printf "%s\t%d\n", path, maxlen > skip
|
|
289
|
+
else for (i = 1; i <= n; i++) print buf[i]
|
|
290
|
+
}
|
|
291
|
+
/^diff --git / {
|
|
292
|
+
flush()
|
|
293
|
+
split("", buf); n = 0; long = 0; maxlen = 0
|
|
294
|
+
path = $0; sub(/^diff --git a\//, "", path); sub(/ b\/.*$/, "", path)
|
|
295
|
+
if (path == "") path = "?"
|
|
296
|
+
}
|
|
297
|
+
{
|
|
298
|
+
if (path == "") { print; next }
|
|
299
|
+
if (length($0) > maxlen) maxlen = length($0)
|
|
300
|
+
if (length($0) > lim) long = 1
|
|
301
|
+
buf[++n] = $0
|
|
302
|
+
}
|
|
303
|
+
END { flush() }'
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
# ---- banner ---------------------------------------------------------------
|
|
307
|
+
banner() { # banner <source-label> <change-label> <what> <command>
|
|
308
|
+
# No line may start with a space: diffnav strips leading whitespace, so any
|
|
309
|
+
# indentation collapses and columns stop lining up.
|
|
310
|
+
printf 'COMPARING %s ==> %s\n' "$1" "$2"
|
|
311
|
+
# base and compare repeat their ref so each row ties back to the first line.
|
|
312
|
+
[ -n "${SRC_TIP:-}" ] && printf 'base %s\n' "$(printf '%s %s' "$1" "$SRC_TIP" | cut -c1-70)"
|
|
313
|
+
[ -n "${CHG_TIP:-}" ] && printf 'compare %s\n' "$(printf '%s %s' "$2" "$CHG_TIP" | cut -c1-70)"
|
|
314
|
+
printf 'showing %s\n' "$3"
|
|
315
|
+
printf 'command %s\n' "$4"
|
|
316
|
+
# A dropped file is named here rather than left to be noticed as missing.
|
|
317
|
+
# The tab is what drop_unrenderable writes between the two fields.
|
|
318
|
+
[ -s "${SKIP:-}" ] && while IFS="$(printf '\t')" read -r p len; do
|
|
319
|
+
printf 'skipped %s (one line is %s chars, and diffnav aborts on it)\n' "$p" "$len"
|
|
320
|
+
done < "$SKIP"
|
|
321
|
+
return 0
|
|
322
|
+
}
|
|
323
|
+
launch() { # launch <source> <change> <what> <git-args...>
|
|
324
|
+
local src="$1" chg="$2" what="$3"; shift 3
|
|
325
|
+
local cmd="git $*"
|
|
326
|
+
local tmp; tmp=$(mktemp); SKIP=$(mktemp)
|
|
327
|
+
git "$@" 2>/dev/null | drop_unrenderable "$SKIP" > "$tmp"
|
|
328
|
+
if grep -q '^diff --git' "$tmp"; then
|
|
329
|
+
# diffnav puts the line after "commit <sha>" in its status bar, and the bar
|
|
330
|
+
# stays put while you walk the file tree. A git show stream carries its own
|
|
331
|
+
# header, so only a plain diff needs one synthesised.
|
|
332
|
+
{ [ "${1:-}" = diff ] && printf 'commit %s\n' "$(git rev-parse HEAD 2>/dev/null)"
|
|
333
|
+
banner "$src" "$chg" "$what" "$cmd"; echo; cat "$tmp"; } | diffnav
|
|
334
|
+
else
|
|
335
|
+
# Nothing for diffnav to draw, for one of two different reasons. The
|
|
336
|
+
# banner and the wait for a keypress are the same either way, so only the
|
|
337
|
+
# explanation sits inside the branch.
|
|
338
|
+
banner "$src" "$chg" "$what" "$cmd"
|
|
339
|
+
echo
|
|
340
|
+
if [ -s "$SKIP" ]; then
|
|
341
|
+
echo " Every file that changed carries a line too long for diffnav."
|
|
342
|
+
echo " They are named above, and nothing else differs between these two."
|
|
343
|
+
else
|
|
344
|
+
echo " No difference between these two. Nothing to show."
|
|
345
|
+
local dirty; dirty=$(git status --porcelain 2>/dev/null | grep -cv '^??')
|
|
346
|
+
if [ "${dirty:-0}" -gt 0 ]; then
|
|
347
|
+
echo " You do have $dirty file(s) changed but not committed."
|
|
348
|
+
echo " Press alt+x and pick \"my uncommitted changes\" to see them."
|
|
349
|
+
else
|
|
350
|
+
echo " Press alt+x to compare something else."
|
|
351
|
+
fi
|
|
352
|
+
fi
|
|
353
|
+
echo; echo "Press any key..."; read -rsn1
|
|
354
|
+
fi
|
|
355
|
+
rm -f "$tmp" "$SKIP"
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
# ---- comparisons ----------------------------------------------------------
|
|
359
|
+
against() { # against <base-ref> <committed|everything>
|
|
360
|
+
local base="$1" mode="$2" mb
|
|
361
|
+
SRC_TIP=$(tip "$base")
|
|
362
|
+
mb=$(git merge-base "$base" HEAD 2>/dev/null); mb=${mb:0:9}
|
|
363
|
+
[ -n "$mb" ] || { echo "No common history between $base and $BRANCH."
|
|
364
|
+
echo; echo "Press any key..."; read -rsn1; return 1; }
|
|
365
|
+
if [ "$mode" = committed ]; then
|
|
366
|
+
CHG_TIP=$(tip HEAD)
|
|
367
|
+
launch "$base" "$BRANCH" \
|
|
368
|
+
"commits on $BRANCH that $base does not have" \
|
|
369
|
+
diff "$base...HEAD"
|
|
370
|
+
else
|
|
371
|
+
CHG_TIP="the files as they sit on disk right now"
|
|
372
|
+
launch "$base" "$BRANCH plus my uncommitted changes" \
|
|
373
|
+
"everything $BRANCH has since it left $base, committed or not" \
|
|
374
|
+
diff "$mb"
|
|
375
|
+
fi
|
|
376
|
+
}
|
|
377
|
+
local_only() {
|
|
378
|
+
SRC_TIP=$(tip HEAD); CHG_TIP="the files as they sit on disk right now"
|
|
379
|
+
launch "the newest commit on $BRANCH" "my uncommitted changes" \
|
|
380
|
+
"edits I have made but not committed, staged or not" diff HEAD
|
|
381
|
+
}
|
|
382
|
+
one_commit() { # one_commit <sha>
|
|
383
|
+
SRC_TIP=$(tip "$1^" 2>/dev/null); CHG_TIP=$(tip "$1")
|
|
384
|
+
launch "the commit just before $1" "commit $1" "what this one commit changed" \
|
|
385
|
+
show "$1" -m --first-parent
|
|
386
|
+
}
|
|
387
|
+
auto() { # fallback when there is no branch comparison to make; $1 says why
|
|
388
|
+
local why="${1:-nothing new to compare against another branch}"
|
|
389
|
+
if ! git diff --quiet || ! git diff --cached --quiet; then local_only
|
|
390
|
+
else SRC_TIP=$(tip HEAD^ 2>/dev/null); CHG_TIP=$(tip HEAD)
|
|
391
|
+
launch "$BRANCH one commit back" "$BRANCH at its newest commit" \
|
|
392
|
+
"$why, and nothing is uncommitted, so here is that commit alone" \
|
|
393
|
+
show HEAD -m --first-parent; fi
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
# ---- picker ---------------------------------------------------------------
|
|
397
|
+
strip_ansi() { sed 's/\x1b\[[0-9;]*m//g'; }
|
|
398
|
+
pick_ref() { # pick_ref <prompt> -> a branch name
|
|
399
|
+
git for-each-ref --sort=-committerdate --format='%(refname:short)' \
|
|
400
|
+
refs/heads refs/remotes 2>/dev/null | grep -v '^origin/HEAD$' \
|
|
401
|
+
| fzf --prompt="$1 > " --no-multi --cycle --preview-window='right,60%' \
|
|
402
|
+
--preview='git log --oneline --decorate --color=always -20 {}'
|
|
403
|
+
}
|
|
404
|
+
pick_commit() { # pick_commit -> a short sha
|
|
405
|
+
git log --oneline --decorate --color=always -300 2>/dev/null \
|
|
406
|
+
| fzf --ansi --prompt="commit > " --no-multi --cycle --preview-window='right,60%' \
|
|
407
|
+
--preview='git show --stat --color=always {1}' \
|
|
408
|
+
| strip_ansi | awk '{print $1}'
|
|
409
|
+
}
|
|
410
|
+
pick() {
|
|
411
|
+
command -v fzf >/dev/null 2>&1 || { echo "fzf is not installed."; echo; auto; return; }
|
|
412
|
+
local dev def choice a b
|
|
413
|
+
dev=$(dev_ref); def=$(default_ref)
|
|
414
|
+
local -a rows=()
|
|
415
|
+
row() { rows+=("$(printf '%s\t%-24s ==> %-30s %s' "$1" "$2" "$3" "$4")"); }
|
|
416
|
+
[ -n "$dev" ] && [ "$dev" != "$BRANCH" ] && {
|
|
417
|
+
row dev-committed "$dev" "$BRANCH" "committed work only"
|
|
418
|
+
row dev-everything "$dev" "$BRANCH + uncommitted" "committed work plus what is not committed"; }
|
|
419
|
+
[ -n "$def" ] && [ "$def" != "$BRANCH" ] && [ "$def" != "$dev" ] && {
|
|
420
|
+
row def-committed "$def" "$BRANCH" "committed work only"
|
|
421
|
+
row def-everything "$def" "$BRANCH + uncommitted" "committed work plus what is not committed"; }
|
|
422
|
+
row local "HEAD" "my uncommitted work" "staged and unstaged changes only"
|
|
423
|
+
row commit "its parent" "one commit" "pick a commit from this branch's history"
|
|
424
|
+
row branch "any branch" "any branch" "pick the source, then the branch to compare"
|
|
425
|
+
choice=$(printf '%s\n' "${rows[@]}" \
|
|
426
|
+
| fzf --delimiter=$'\t' --with-nth=2.. --no-multi --cycle \
|
|
427
|
+
--prompt="diff > " --header="on $BRANCH · source ==> change" \
|
|
428
|
+
| cut -f1)
|
|
429
|
+
case "$choice" in
|
|
430
|
+
dev-committed) against "$dev" committed ;;
|
|
431
|
+
dev-everything) against "$dev" everything ;;
|
|
432
|
+
def-committed) against "$def" committed ;;
|
|
433
|
+
def-everything) against "$def" everything ;;
|
|
434
|
+
local) local_only ;;
|
|
435
|
+
commit) a=$(pick_commit); [ -n "$a" ] && one_commit "$a" ;;
|
|
436
|
+
branch) a=$(pick_ref "source (compare against)") ; [ -z "$a" ] && exit 0
|
|
437
|
+
b=$(pick_ref "change (the new work)") ; [ -z "$b" ] && exit 0
|
|
438
|
+
SRC_TIP=$(tip "$a"); CHG_TIP=$(tip "$b")
|
|
439
|
+
launch "$a" "$b" "commits on $b that $a does not have" diff "$a...$b" ;;
|
|
440
|
+
*) exit 0 ;;
|
|
441
|
+
esac
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
# ---- entry ----------------------------------------------------------------
|
|
445
|
+
default_compare() { # alt+f: the newest commit on this branch, against origin/dev
|
|
446
|
+
local base; base=$(dev_ref) || base=$(default_ref)
|
|
447
|
+
# Only when this checkout IS the base branch is there nothing to compare.
|
|
448
|
+
if [ -z "$base" ] || [ "$base" = "$BRANCH" ] || [ "origin/$BRANCH" = "$base" ]; then
|
|
449
|
+
auto "$BRANCH is the branch everything else compares against"; return; fi
|
|
450
|
+
against "$base" committed
|
|
451
|
+
}
|
|
452
|
+
case "${1:-}" in
|
|
453
|
+
"") default_compare ;;
|
|
454
|
+
pick) pick ;;
|
|
455
|
+
auto) auto ;;
|
|
456
|
+
*) echo "usage: acstack-diff [pick|auto]"; exit 2 ;;
|
|
457
|
+
esac
|
|
458
|
+
H
|
|
459
|
+
chmod +x "$BIN/acstack-diff"
|
|
460
|
+
|
|
461
|
+
cat > "$BIN/acstack-dash" <<'H'
|
|
462
|
+
#!/usr/bin/env bash
|
|
463
|
+
# gh-dash with a section for the repo you are standing in. Installed by acstack.
|
|
464
|
+
set -uo pipefail
|
|
465
|
+
# Every action gh-dash takes shells out to gh and discards its stderr, so a
|
|
466
|
+
# refusal from GitHub reaches you as "exit status 1" for two seconds. acstack-gh
|
|
467
|
+
# goes in front of the real gh under that name and speaks the refusal aloud.
|
|
468
|
+
# Its directory holds nothing else, so nothing else on PATH is shadowed.
|
|
469
|
+
SHIM="$(mktemp -d)"; GEN=""
|
|
470
|
+
trap 'rm -rf "$SHIM"; rm -f "$GEN"' EXIT
|
|
471
|
+
ln -s "$(command -v acstack-gh)" "$SHIM/gh" 2>/dev/null && export PATH="$SHIM:$PATH"
|
|
472
|
+
BASE="$HOME/.config/gh-dash/config.yml"
|
|
473
|
+
REPO="$(gh repo view --json nameWithOwner -q .nameWithOwner 2>/dev/null)"
|
|
474
|
+
# Not exec, so the trap above still gets to clean up after the dashboard.
|
|
475
|
+
[ -z "$REPO" ] && { gh dash "$@"; exit $?; }
|
|
476
|
+
GEN="$(mktemp)"
|
|
477
|
+
REPO="$REPO" python3 - "$BASE" "$GEN" <<'PY'
|
|
478
|
+
import os, sys, yaml
|
|
479
|
+
cfg = yaml.safe_load(open(sys.argv[1])) or {}
|
|
480
|
+
repo = os.environ["REPO"]; short = repo.split("/")[-1]
|
|
481
|
+
s = [x for x in cfg.get("prSections", []) if x.get("title") != short]
|
|
482
|
+
s.insert(0, {"title": short, "filters": f"repo:{repo} is:open"})
|
|
483
|
+
cfg["prSections"] = s
|
|
484
|
+
yaml.safe_dump(cfg, open(sys.argv[2], "w"), sort_keys=False)
|
|
485
|
+
PY
|
|
486
|
+
gh dash --config "$GEN" "$@"
|
|
487
|
+
H
|
|
488
|
+
chmod +x "$BIN/acstack-dash"
|
|
489
|
+
|
|
490
|
+
cat > "$BIN/acstack-db" <<'H'
|
|
491
|
+
#!/usr/bin/env bash
|
|
492
|
+
# Open harlequin, the SQL editor, on one of your connection profiles.
|
|
493
|
+
#
|
|
494
|
+
# acstack-db one profile launches straight in, more than one opens a menu
|
|
495
|
+
# acstack-db <profile> that profile, no menu
|
|
496
|
+
#
|
|
497
|
+
# Profiles live in a TOML file of your own. This writes a starter the first
|
|
498
|
+
# time and never touches it again. Installed by acstack.
|
|
499
|
+
set -uo pipefail
|
|
500
|
+
# apply rewrites this line from harlequin.config_path. What ships here is the
|
|
501
|
+
# default, so the helper is right even on a machine nothing rewrote it on.
|
|
502
|
+
DB_CFG_DEFAULT="~/.config/acstack/harlequin.toml" # acstack:config_path
|
|
503
|
+
CFG="${ACSTACK_DB_CONFIG:-$DB_CFG_DEFAULT}"
|
|
504
|
+
# A human writes ~ for home, and a human writes this config. Expanding it here
|
|
505
|
+
# rather than at write time keeps the helper right on a machine whose HOME is
|
|
506
|
+
# not the one apply ran under.
|
|
507
|
+
case "$CFG" in
|
|
508
|
+
"~") CFG="$HOME" ;;
|
|
509
|
+
"~/"*) CFG="$HOME/${CFG#\~/}" ;;
|
|
510
|
+
esac
|
|
511
|
+
|
|
512
|
+
command -v harlequin >/dev/null 2>&1 || {
|
|
513
|
+
echo "harlequin is not installed. Run: setup.sh apply"; exit 1; }
|
|
514
|
+
|
|
515
|
+
if [ ! -f "$CFG" ]; then
|
|
516
|
+
mkdir -p "$(dirname "$CFG")"
|
|
517
|
+
cat > "$CFG" <<'T'
|
|
518
|
+
# harlequin connection profiles. acstack wrote this once and will not write it
|
|
519
|
+
# again. Edit it freely.
|
|
520
|
+
default_profile = "local"
|
|
521
|
+
|
|
522
|
+
[profiles.local]
|
|
523
|
+
adapter = "postgres"
|
|
524
|
+
conn_str = "postgres://localhost:5432/postgres"
|
|
525
|
+
|
|
526
|
+
# Snowflake. Take the # marks off and fill in your own account.
|
|
527
|
+
#
|
|
528
|
+
# Put the credentials in ~/.snowflake/connections.toml and name the connection
|
|
529
|
+
# here, rather than writing them into this file.
|
|
530
|
+
#
|
|
531
|
+
# externalbrowser authentication needs a browser on this machine. A dev machine
|
|
532
|
+
# reached over SSH has none, so use a key pair or a password instead.
|
|
533
|
+
#
|
|
534
|
+
# [profiles.snowflake]
|
|
535
|
+
# adapter = "snowflake"
|
|
536
|
+
# connection_name = "default"
|
|
537
|
+
T
|
|
538
|
+
echo "wrote a starter config at $CFG - edit it to reach your own database"
|
|
539
|
+
fi
|
|
540
|
+
|
|
541
|
+
launch() { exec harlequin --config-path "$CFG" -P "$1"; }
|
|
542
|
+
[ $# -gt 0 ] && launch "$1"
|
|
543
|
+
|
|
544
|
+
# Profile names, read with a regex rather than tomllib: tomllib needs python
|
|
545
|
+
# 3.11 and nothing else this script writes has a floor that high. Names are all
|
|
546
|
+
# that is wanted here, and a table header is the one line a regex reads safely.
|
|
547
|
+
rows=$(python3 - "$CFG" <<'PY'
|
|
548
|
+
import re, sys
|
|
549
|
+
text = open(sys.argv[1]).read()
|
|
550
|
+
d = re.search(r'(?m)^\s*default_profile\s*=\s*"([^"]+)"', text)
|
|
551
|
+
default = d.group(1) if d else ""
|
|
552
|
+
for m in re.finditer(r'(?m)^\s*\[profiles\.("?)([^\]"]+)\1\]\s*$', text):
|
|
553
|
+
name = m.group(2)
|
|
554
|
+
body = text[m.end():].split("\n[", 1)[0]
|
|
555
|
+
a = re.search(r'(?m)^\s*adapter\s*=\s*"([^"]+)"', body)
|
|
556
|
+
mark = " (default)" if name == default else ""
|
|
557
|
+
print("%s\t%-24s %-12s%s" % (name, name, a.group(1) if a else "?", mark))
|
|
558
|
+
PY
|
|
559
|
+
)
|
|
560
|
+
count=$(printf '%s\n' "$rows" | grep -c .)
|
|
561
|
+
case "$count" in
|
|
562
|
+
0) echo "no profiles in $CFG"; exit 1 ;;
|
|
563
|
+
1) launch "$(printf '%s' "$rows" | cut -f1)" ;;
|
|
564
|
+
esac
|
|
565
|
+
|
|
566
|
+
command -v fzf >/dev/null 2>&1 || {
|
|
567
|
+
echo "fzf is not installed, so there is no menu. Naming a profile still works:"
|
|
568
|
+
printf '%s\n' "$rows" | cut -f1 | sed 's/.*/ acstack-db "&"/'
|
|
569
|
+
exit 1; }
|
|
570
|
+
|
|
571
|
+
choice=$(printf '%s\n' "$rows" \
|
|
572
|
+
| fzf --delimiter=$'\t' --with-nth=2.. --no-multi --cycle \
|
|
573
|
+
--prompt="db > " --header="harlequin · profile adapter" \
|
|
574
|
+
| cut -f1)
|
|
575
|
+
[ -n "$choice" ] || exit 0
|
|
576
|
+
launch "$choice"
|
|
577
|
+
H
|
|
578
|
+
chmod +x "$BIN/acstack-db"
|
|
579
|
+
# The one helper with a path in the config, so the one write that is not a
|
|
580
|
+
# heredoc on its own. The line above already holds the default, so this
|
|
581
|
+
# carries nothing but a value the user chose.
|
|
582
|
+
ACSTACK_DB_CFG="$(cfg_get harlequin.config_path "~/.config/acstack/harlequin.toml")" \
|
|
583
|
+
py "$BIN/acstack-db" <<'PY'
|
|
584
|
+
import os, re, sys
|
|
585
|
+
# A bash single-quoted literal, so a path with a space or a quote in it reaches
|
|
586
|
+
# the helper as one word rather than as shell syntax.
|
|
587
|
+
v = "'" + os.environ["ACSTACK_DB_CFG"].replace("'", "'\\''") + "'"
|
|
588
|
+
p = sys.argv[1]
|
|
589
|
+
t = open(p).read()
|
|
590
|
+
t, n = re.subn(r"(?m)^DB_CFG_DEFAULT=.*# acstack:config_path$",
|
|
591
|
+
lambda m: "DB_CFG_DEFAULT=" + v + " # acstack:config_path", t)
|
|
592
|
+
if n:
|
|
593
|
+
open(p, "w").write(t)
|
|
594
|
+
PY
|
|
595
|
+
|
|
596
|
+
cat > "$BIN/acstack-gh" <<'H'
|
|
597
|
+
#!/usr/bin/env bash
|
|
598
|
+
# gh, plus the reason a pull request action failed.
|
|
599
|
+
#
|
|
600
|
+
# gh-dash runs gh for everything it does to a pull request - approve, comment,
|
|
601
|
+
# merge - and throws the command's stderr away, so GitHub refusing one shows as
|
|
602
|
+
# "exit status 1" in the footer for two seconds and nothing else. Approving your
|
|
603
|
+
# own pull request is the everyday case: GitHub always refuses that, and the
|
|
604
|
+
# dashboard looks like it simply ignored the keypress.
|
|
605
|
+
#
|
|
606
|
+
# acstack-dash links this into a directory of its own at the front of $PATH
|
|
607
|
+
# under the name gh, so gh-dash finds it first. Everything is handed to the real
|
|
608
|
+
# gh untouched and its exit code is returned as-is; the only thing added is a
|
|
609
|
+
# notification when an action fails, because a suspended TUI cannot print for
|
|
610
|
+
# us. Installed by acstack.
|
|
611
|
+
set -uo pipefail
|
|
612
|
+
|
|
613
|
+
# The real gh is the first one on $PATH that is not this script under another
|
|
614
|
+
# name. Comparing what each resolves to sees through both the link acstack-dash
|
|
615
|
+
# makes and a plain copy of this file.
|
|
616
|
+
ME="$(readlink -f "$0")"
|
|
617
|
+
REAL=""
|
|
618
|
+
while IFS= read -r dir; do
|
|
619
|
+
[ -n "$dir" ] && [ -x "$dir/gh" ] || continue
|
|
620
|
+
[ "$(readlink -f "$dir/gh")" = "$ME" ] && continue
|
|
621
|
+
REAL="$dir/gh"; break
|
|
622
|
+
done < <(printf '%s\n' "${PATH//:/$'\n'}")
|
|
623
|
+
[ -n "$REAL" ] || { echo "acstack-gh: no gh on \$PATH besides this shim" >&2; exit 127; }
|
|
624
|
+
|
|
625
|
+
# The dashboard is not an action: it owns the terminal for as long as it runs.
|
|
626
|
+
[ "${1:-}" = "dash" ] && exec "$REAL" "$@"
|
|
627
|
+
|
|
628
|
+
ERR="$(mktemp)"; trap 'rm -f "$ERR"' EXIT
|
|
629
|
+
"$REAL" "$@" 2>"$ERR"
|
|
630
|
+
RC=$?
|
|
631
|
+
cat "$ERR" >&2
|
|
632
|
+
|
|
633
|
+
# Only what a dashboard key fires. gh's own plumbing - repo view, api, auth -
|
|
634
|
+
# fails for reasons a notification cannot help with, and acstack-dash asks gh
|
|
635
|
+
# which repository this is before the dashboard has even started.
|
|
636
|
+
case "${1:-}" in pr|issue) ;; *) exit $RC ;; esac
|
|
637
|
+
[ "$RC" -eq 0 ] && exit 0
|
|
638
|
+
|
|
639
|
+
MSG="$(grep -v '^[[:space:]]*$' "$ERR" | head -3)"
|
|
640
|
+
[ -n "$MSG" ] || MSG="gh exited with status $RC"
|
|
641
|
+
command -v herdr >/dev/null 2>&1 \
|
|
642
|
+
&& herdr notification show "gh ${1:-} ${2:-} failed" --body "$MSG" >/dev/null 2>&1
|
|
643
|
+
exit $RC
|
|
644
|
+
H
|
|
645
|
+
chmod +x "$BIN/acstack-gh"
|
|
646
|
+
|
|
647
|
+
cat > "$BIN/acstack-md" <<'H'
|
|
648
|
+
#!/usr/bin/env bash
|
|
649
|
+
# Read a markdown file in the terminal with its mermaid diagrams drawn as text.
|
|
650
|
+
#
|
|
651
|
+
# acstack-md FILE.md render to the terminal
|
|
652
|
+
# acstack-md --preview FILE.md render into a preview pane: width from
|
|
653
|
+
# $COLUMNS, straight to stdout, no pager
|
|
654
|
+
# acstack-md --numbered FILE.md the source instead, line numbers down the
|
|
655
|
+
# left, for quoting a line back in a review
|
|
656
|
+
# acstack-md --toggle-line-numbers flip which of the two --preview shows
|
|
657
|
+
# acstack-md --diagram N FILE.md one diagram alone, with horizontal scroll
|
|
658
|
+
# acstack-md --serve FILE.md serve it for a real browser, print the link
|
|
659
|
+
# acstack-md --html FILE.md print that same page as one HTML document
|
|
660
|
+
# acstack-md --browser FILE.md open it in the browser you are sitting at
|
|
661
|
+
#
|
|
662
|
+
# While reading, b opens the page in that browser and # switches between the
|
|
663
|
+
# rendered document and its source with line numbers. $ACSTACK_MD_BROWSER_KEY
|
|
664
|
+
# and $ACSTACK_MD_LINE_NUMBERS_KEY move them.
|
|
665
|
+
#
|
|
666
|
+
# Text, not images, is deliberate: herdr emits the kitty graphics protocol and
|
|
667
|
+
# no terminal reachable from Windows or Android renders that, and e-ink panels
|
|
668
|
+
# render text far better than bitmaps anyway. Installed by acstack.
|
|
669
|
+
set -uo pipefail
|
|
670
|
+
|
|
671
|
+
PORT="${ACSTACK_MD_PORT:-6419}"
|
|
672
|
+
# Named rather than inherited from an ambient $AGENT_BROWSER_CDP, for the
|
|
673
|
+
# reason acstack-open gives at its own bridge branch: leaning on the ambient
|
|
674
|
+
# one launches a headless Chrome nobody can see and calls it success.
|
|
675
|
+
BRIDGE="${ACSTACK_BRIDGE_URL:-http://127.0.0.1:19988}"
|
|
676
|
+
OPENER="${ACSTACK_OPENER_URL:-http://127.0.0.1:19989}"
|
|
677
|
+
# The opener refuses anything without this, so its absence is the same as the
|
|
678
|
+
# opener being down - which is exactly how the route probe treats it.
|
|
679
|
+
OPENER_TOKEN="${XDG_CONFIG_HOME:-$HOME/.config}/acstack/opener.token"
|
|
680
|
+
SERVE=0; ONLY=0; PREVIEW=0; HTML=0; BROWSER=0; NUMBERED=0; TOGGLE=0
|
|
681
|
+
usage() { echo "usage: acstack-md [--preview] [--numbered] [--toggle-line-numbers] [--diagram N] [--serve|--html|--browser] FILE.md" >&2; exit 1; }
|
|
682
|
+
# The preview pane and the key that flips it are two processes that never meet,
|
|
683
|
+
# so the choice between the rendered view and the source lives in a file both
|
|
684
|
+
# of them can find. One name, defined once, is what keeps them agreeing.
|
|
685
|
+
LINE_NUMBER_FLAG="${XDG_STATE_HOME:-$HOME/.local/state}/acstack/md-line-numbers"
|
|
686
|
+
# A loop rather than a fixed order, so --preview composes with --diagram and
|
|
687
|
+
# a caller can pass them either way round.
|
|
688
|
+
while [ $# -gt 0 ]; do
|
|
689
|
+
case "$1" in
|
|
690
|
+
--serve) SERVE=1; shift ;;
|
|
691
|
+
--html) HTML=1; shift ;;
|
|
692
|
+
--browser) BROWSER=1; shift ;;
|
|
693
|
+
--preview) PREVIEW=1; shift ;;
|
|
694
|
+
--numbered) NUMBERED=1; shift ;;
|
|
695
|
+
--toggle-line-numbers) TOGGLE=1; shift ;;
|
|
696
|
+
--diagram) ONLY="${2:-1}"; shift; [ $# -gt 0 ] && shift ;;
|
|
697
|
+
--) shift; break ;;
|
|
698
|
+
--*) usage ;;
|
|
699
|
+
*) break ;;
|
|
700
|
+
esac
|
|
701
|
+
done
|
|
702
|
+
# The toggle names no file: it changes what the next preview will draw and has
|
|
703
|
+
# nothing to draw itself. So it answers here, before the file check below.
|
|
704
|
+
if [ "$TOGGLE" = "1" ]; then
|
|
705
|
+
if [ -e "$LINE_NUMBER_FLAG" ]; then
|
|
706
|
+
rm -f "$LINE_NUMBER_FLAG"
|
|
707
|
+
echo "acstack-md: markdown previews render again"
|
|
708
|
+
else
|
|
709
|
+
# A machine that has never had a state directory is every machine the
|
|
710
|
+
# first time, so make it rather than fail on it.
|
|
711
|
+
mkdir -p "$(dirname "$LINE_NUMBER_FLAG")" && : > "$LINE_NUMBER_FLAG" \
|
|
712
|
+
|| { echo "acstack-md: could not write $LINE_NUMBER_FLAG" >&2; exit 1; }
|
|
713
|
+
echo "acstack-md: markdown previews show source with line numbers"
|
|
714
|
+
fi
|
|
715
|
+
exit 0
|
|
716
|
+
fi
|
|
717
|
+
# --preview, --serve and --html are three answers to the same question - where
|
|
718
|
+
# does this end up - so at most one can be true. A preview pane is somewhere to
|
|
719
|
+
# read, not somewhere to run a server or hand a document out of.
|
|
720
|
+
if [ $((SERVE + PREVIEW + HTML + BROWSER)) -gt 1 ]; then
|
|
721
|
+
echo "acstack-md: --preview, --serve, --html and --browser do different things - pick one" >&2
|
|
722
|
+
exit 1
|
|
723
|
+
fi
|
|
724
|
+
# --numbered answers a different question - which of the two views - so it
|
|
725
|
+
# composes with --preview and with nothing else. The others all want the
|
|
726
|
+
# rendered document, and numbered source is not one.
|
|
727
|
+
if [ "$NUMBERED" = "1" ] && [ $((SERVE + HTML + BROWSER)) -gt 0 ]; then
|
|
728
|
+
echo "acstack-md: --numbered prints the source, so --serve, --html and --browser have nothing to render" >&2
|
|
729
|
+
exit 1
|
|
730
|
+
fi
|
|
731
|
+
# The flag is what the file manager's key flips, and the pane is the only
|
|
732
|
+
# place it applies: every other route was asked for a rendered document by
|
|
733
|
+
# name, and the opener menu lists the numbered read as its own entry.
|
|
734
|
+
#
|
|
735
|
+
# --diagram is the exception the pane itself holds. It names one drawing to
|
|
736
|
+
# show, which is a different question from which of the two views the pane is
|
|
737
|
+
# on, and a reader who left the flag set and then asked for a diagram still
|
|
738
|
+
# wants the diagram. So an explicit --diagram outranks the flag. An explicit
|
|
739
|
+
# --numbered still wins over it, for the same reason in the other direction.
|
|
740
|
+
if [ "$PREVIEW" = "1" ] && [ "$ONLY" = "0" ] && [ -e "$LINE_NUMBER_FLAG" ]; then NUMBERED=1; fi
|
|
741
|
+
# Node labels set a diagram's width, so padding cannot rescue a wide one.
|
|
742
|
+
# Tightening still buys roughly a third of the height back.
|
|
743
|
+
PAD="${ACSTACK_MD_PAD:--x 2 -y 2}"
|
|
744
|
+
# A caller that exports COLUMNS means it, so it wins outright. tput reads
|
|
745
|
+
# COLUMNS too, ahead of terminfo, but that is a side effect worth naming
|
|
746
|
+
# rather than resting on - it goes when TERM is unset and tput fails.
|
|
747
|
+
case "${COLUMNS:-}" in
|
|
748
|
+
''|*[!0-9]*) COLS=$(tput cols 2>/dev/null || echo 80) ;;
|
|
749
|
+
*) COLS="$COLUMNS" ;;
|
|
750
|
+
esac
|
|
751
|
+
[ "$COLS" -gt 0 ] 2>/dev/null || COLS=80
|
|
752
|
+
FILE="${1:-}"
|
|
753
|
+
[ -n "$FILE" ] && [ -f "$FILE" ] || usage
|
|
754
|
+
# One place to clean up, so no exit path leaks a temp file.
|
|
755
|
+
trap "rm -f /tmp/acstack-md.$$.md /tmp/acstack-md-out.$$.md /tmp/acstack-md-art.$$.* /tmp/acstack-md-keys.$$" EXIT
|
|
756
|
+
|
|
757
|
+
# Wanting a document in a browser happens while reading it, so the key belongs
|
|
758
|
+
# in the pager rather than before or after one.
|
|
759
|
+
#
|
|
760
|
+
# less has no action that runs a fixed command, and its one shell escape
|
|
761
|
+
# expands % to the file it was handed - which here is the rendered temp copy,
|
|
762
|
+
# not the markdown someone asked for. So the key quits instead: lesskey's quit
|
|
763
|
+
# action takes the first character of its extra string as the exit status, and
|
|
764
|
+
# "A" is 65, a value less never returns of its own accord. Nothing has to be
|
|
765
|
+
# passed through the binding, because this script already knows the file.
|
|
766
|
+
BROWSE_STATUS=65
|
|
767
|
+
BROWSE_KEY="${ACSTACK_MD_BROWSER_KEY:-b}"
|
|
768
|
+
# The same trick a second time, for the other thing a reader wants mid-document.
|
|
769
|
+
# Wanting a line number happens while reading too - that is the moment you are
|
|
770
|
+
# about to quote one - and by then the file list with its own binding is not in
|
|
771
|
+
# front of you. So the key means the same thing in both places.
|
|
772
|
+
NUMBER_STATUS=66
|
|
773
|
+
NUMBER_KEY="${ACSTACK_MD_LINE_NUMBERS_KEY:-#}"
|
|
774
|
+
KEYS="/tmp/acstack-md-keys.$$"
|
|
775
|
+
|
|
776
|
+
# A preview pane cannot page, so the same render goes straight to stdout.
|
|
777
|
+
emit() {
|
|
778
|
+
local f="$1" nk rc; shift
|
|
779
|
+
if [ "$PREVIEW" = "1" ]; then cat "$f"; return; fi
|
|
780
|
+
# The binding is a less feature, so only less is handed it. Setting
|
|
781
|
+
# LESSKEYIN for a pager that never reads it is at best noise, and acting on
|
|
782
|
+
# an exit status that pager chose for its own reasons is a bug waiting.
|
|
783
|
+
case "${1:-}" in
|
|
784
|
+
less|*/less)
|
|
785
|
+
# b is back-a-page in less. ^B and PageUp both still do that, so this
|
|
786
|
+
# costs nothing (measured). LESSKEYIN wants less 582 or newer; older
|
|
787
|
+
# versions ignore it and quietly keep b as it was.
|
|
788
|
+
#
|
|
789
|
+
# lesskey reads a line starting with # as a comment, so the key that
|
|
790
|
+
# means line numbers has to be escaped or the binding is silently
|
|
791
|
+
# dropped and the key does nothing but ring the bell - measured on less
|
|
792
|
+
# 590, and how this arrived as a bug report. Only a leading # needs it:
|
|
793
|
+
# backslash means something of its own to lesskey, and \b would bind
|
|
794
|
+
# backspace rather than the letter.
|
|
795
|
+
case "$NUMBER_KEY" in
|
|
796
|
+
'#'*) nk="\\$NUMBER_KEY" ;;
|
|
797
|
+
*) nk="$NUMBER_KEY" ;;
|
|
798
|
+
esac
|
|
799
|
+
{ printf '#command\n'
|
|
800
|
+
printf '%s quit A\n' "$BROWSE_KEY"
|
|
801
|
+
printf '%s quit B\n' "$nk"
|
|
802
|
+
} > "$KEYS"
|
|
803
|
+
LESSKEYIN="$KEYS" "$@" "$f"
|
|
804
|
+
rc=$?
|
|
805
|
+
if [ "$rc" = "$BROWSE_STATUS" ]; then
|
|
806
|
+
"$0" --browser "$FILE"
|
|
807
|
+
exit $?
|
|
808
|
+
fi
|
|
809
|
+
if [ "$rc" = "$NUMBER_STATUS" ]; then
|
|
810
|
+
# One key, both directions: the view you are not in is the one it
|
|
811
|
+
# takes you to. $FILE is the markdown throughout, never the rendered
|
|
812
|
+
# temp copy, so either re-read starts from the source again.
|
|
813
|
+
if [ "$NUMBERED" = "1" ]; then "$0" "$FILE"; else "$0" --numbered "$FILE"; fi
|
|
814
|
+
exit $?
|
|
815
|
+
fi
|
|
816
|
+
;;
|
|
817
|
+
*) "$@" "$f" ;;
|
|
818
|
+
esac
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
# A wide diagram left a marker behind rather than art glow would wrap. Turn
|
|
822
|
+
# the marker into a line a person can read and put the drawing back under it.
|
|
823
|
+
# Both output paths run this: without it the marker reaches the reader raw.
|
|
824
|
+
splice() {
|
|
825
|
+
ACSTACK_PID=$$ ACSTACK_PREVIEW="$PREVIEW" python3 - "$1" <<'PY'
|
|
826
|
+
import os, pathlib, re, sys
|
|
827
|
+
f = pathlib.Path(sys.argv[1])
|
|
828
|
+
pid = os.environ["ACSTACK_PID"]
|
|
829
|
+
preview = os.environ.get("ACSTACK_PREVIEW") == "1"
|
|
830
|
+
lines = []
|
|
831
|
+
for line in f.read_text().split("\n"):
|
|
832
|
+
m = re.search(r"\x00ACSTACKART(\d+)\x00", line)
|
|
833
|
+
if not m:
|
|
834
|
+
lines.append(line); continue
|
|
835
|
+
art = pathlib.Path(f"/tmp/acstack-md-art.{pid}.{m.group(1)}")
|
|
836
|
+
lines.append(re.sub(r"\x00ACSTACKART\d+\x00", f"Diagram {m.group(1)}:", line))
|
|
837
|
+
if art.exists():
|
|
838
|
+
# Art too wide for the pane would spill past it, so a preview pane
|
|
839
|
+
# keeps the placeholder and leaves the drawing to a full-width read.
|
|
840
|
+
if not preview:
|
|
841
|
+
lines.extend(" " + l for l in art.read_text().splitlines())
|
|
842
|
+
art.unlink()
|
|
843
|
+
f.write_text("\n".join(lines) + "\n")
|
|
844
|
+
PY
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
# Rendered markdown has no line numbers, and a reader who wants to quote a
|
|
848
|
+
# line back in a review needs the file's own. The gutter is as wide as the
|
|
849
|
+
# largest number in it, so the source stays on one column whatever the length.
|
|
850
|
+
numbered() { # numbered <file> -> stdout
|
|
851
|
+
local total width avail
|
|
852
|
+
total=$(awk 'END { print NR }' "$1")
|
|
853
|
+
width=${#total}
|
|
854
|
+
[ "$width" -lt 3 ] && width=3
|
|
855
|
+
if [ "$PREVIEW" = "1" ]; then
|
|
856
|
+
# A wrapped line makes the gutter lie about which source line you are
|
|
857
|
+
# looking at, and a pane has no arrow keys to pan with, so it chops.
|
|
858
|
+
avail=$((COLS - width - 2))
|
|
859
|
+
[ "$avail" -lt 1 ] && avail=1
|
|
860
|
+
awk -v w="$width" -v a="$avail" \
|
|
861
|
+
'{ printf "%" w "d %s\n", NR, substr($0, 1, a) }' "$1"
|
|
862
|
+
else
|
|
863
|
+
awk -v w="$width" '{ printf "%" w "d %s\n", NR, $0 }' "$1"
|
|
864
|
+
fi
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if [ "$NUMBERED" = "1" ]; then
|
|
868
|
+
numbered "$FILE" > /tmp/acstack-md.$$.md
|
|
869
|
+
# -S chops rather than wraps, so the arrow keys pan across a long line
|
|
870
|
+
# instead of the gutter losing its column. emit sends a pane straight to
|
|
871
|
+
# stdout and keeps the browser key working in the pager, both unchanged.
|
|
872
|
+
emit /tmp/acstack-md.$$.md less -SR
|
|
873
|
+
exit 0
|
|
874
|
+
fi
|
|
875
|
+
|
|
876
|
+
# --browser names a destination rather than a rendering: work out where the
|
|
877
|
+
# browser actually is, then pick the delivery that reaches it. acstack-open
|
|
878
|
+
# already answers that question for URLs, so ask it rather than deciding the
|
|
879
|
+
# same thing twice and drifting from it later.
|
|
880
|
+
if [ "$BROWSER" = "1" ]; then
|
|
881
|
+
case "$(command -v acstack-open >/dev/null 2>&1 && acstack-open --why 2>/dev/null)" in
|
|
882
|
+
opener)
|
|
883
|
+
# The opener stores the document on the laptop and serves it to the
|
|
884
|
+
# browser there, so the page still works once this machine stops
|
|
885
|
+
# answering - and a re-render of the same file updates the tab already
|
|
886
|
+
# showing it instead of stacking up a second one. That reuse is why the
|
|
887
|
+
# key has to be stable per file: it is the absolute path, hex-encoded
|
|
888
|
+
# because a path may hold anything a URL cannot.
|
|
889
|
+
tok=$(cat "$OPENER_TOKEN" 2>/dev/null || true)
|
|
890
|
+
if [ -z "$tok" ]; then
|
|
891
|
+
echo "acstack-md: no opener token at $OPENER_TOKEN." >&2
|
|
892
|
+
echo " run register-tasks.ps1 on the laptop to install one." >&2
|
|
893
|
+
exit 1
|
|
894
|
+
fi
|
|
895
|
+
hex() { od -An -v -tx1 | tr -d ' \n'; }
|
|
896
|
+
key=$(printf '%s' "$(cd "$(dirname "$FILE")" && pwd -P)/$(basename "$FILE")" | hex)
|
|
897
|
+
name=$(basename "$FILE")
|
|
898
|
+
# Vendored mermaid, uploaded once so diagrams draw without reaching a
|
|
899
|
+
# CDN. Best effort throughout: the opener redirects to the CDN when it
|
|
900
|
+
# has no copy, so a failure here costs nothing but an outbound fetch.
|
|
901
|
+
vendored="$HOME/.local/share/acstack/mermaid.min.js"
|
|
902
|
+
# tr, because whitespace inside JSON carries no meaning and a match that
|
|
903
|
+
# depends on its absence is a match waiting to break. Same reasoning as
|
|
904
|
+
# acstack-open's /json/list check.
|
|
905
|
+
if [ -s "$vendored" ] && ! curl -fsS -m 3 -H "X-ACStack-Token: $tok" \
|
|
906
|
+
"$OPENER/health" 2>/dev/null | tr -d '[:space:]' | grep -q '"mermaid":true'; then
|
|
907
|
+
curl -fsS -m 60 -X POST -H "X-ACStack-Token: $tok" \
|
|
908
|
+
--data-binary "@$vendored" "$OPENER/asset/mermaid.js" >/dev/null 2>&1 || true
|
|
909
|
+
fi
|
|
910
|
+
# The opener answers with what it did, and that answer is what success
|
|
911
|
+
# means - it says whether a tab was opened or a live one reused. An exit
|
|
912
|
+
# status of 0 is not a page that arrived.
|
|
913
|
+
answer=$(ACSTACK_MD_OPENER=1 "$0" --html "$FILE" \
|
|
914
|
+
| curl -fsS -m 60 -X POST -H "X-ACStack-Token: $tok" \
|
|
915
|
+
-H 'Content-Type: text/html; charset=utf-8' --data-binary @- \
|
|
916
|
+
"$OPENER/doc?key=$key&title=$(printf '%s' "$name" | hex)" 2>/dev/null \
|
|
917
|
+
| tr -d '[:space:]')
|
|
918
|
+
case "$answer" in
|
|
919
|
+
*'"reused":true'*)
|
|
920
|
+
printf '%s updated in the tab already open on your machine\n' "$name" >&2
|
|
921
|
+
exit 0 ;;
|
|
922
|
+
*'"opened":true'*)
|
|
923
|
+
printf '%s is on screen in the browser on your machine\n' "$name" >&2
|
|
924
|
+
exit 0 ;;
|
|
925
|
+
esac
|
|
926
|
+
echo "acstack-md: the opener did not confirm the page reached a browser." >&2
|
|
927
|
+
echo " check it is up: acstack-open --why" >&2
|
|
928
|
+
exit 1
|
|
929
|
+
;;
|
|
930
|
+
bridge)
|
|
931
|
+
# Under `herdr --remote` the tunnel runs laptop-to-here, so nothing over
|
|
932
|
+
# there can open a port on this machine and a link to localhost:6419
|
|
933
|
+
# names the wrong localhost. Hand the whole document down the CDP link
|
|
934
|
+
# that is already open instead, and let Chrome hold it. That is also why
|
|
935
|
+
# --html exists: a document travels, a port does not.
|
|
936
|
+
#
|
|
937
|
+
# $0 is the path bash resolved this script to - absolute when it came off
|
|
938
|
+
# PATH - so the page pushed is the one --html renders, not a second copy
|
|
939
|
+
# of the renderer that can disagree with it.
|
|
940
|
+
html=$("$0" --html "$FILE") || exit 1
|
|
941
|
+
# base64 because the document is full of quotes and newlines and has to
|
|
942
|
+
# survive being a JavaScript string literal. tr rather than `base64 -w0`,
|
|
943
|
+
# which is GNU-only.
|
|
944
|
+
payload=$(printf '%s' "$html" | base64 | tr -d '\n')
|
|
945
|
+
# A tab of its own, and never over a page that is already there. The
|
|
946
|
+
# extension attaches per tab through chrome.debugger, and rewriting a
|
|
947
|
+
# real document detaches it - measured: pushing over an https page left
|
|
948
|
+
# /json/list empty and the bridge unusable until it was armed again by
|
|
949
|
+
# hand. A fresh about:blank tab survives the same rewrite, because the
|
|
950
|
+
# document being replaced has the same origin. So the armed tab is left
|
|
951
|
+
# exactly as it was, and this writes into a new one.
|
|
952
|
+
#
|
|
953
|
+
# The default session deliberately, as acstack-open explains: the relay
|
|
954
|
+
# takes one CDP client at a time, so a session of our own could not
|
|
955
|
+
# connect while an agent holds it.
|
|
956
|
+
#
|
|
957
|
+
# The last expression is the page title, so the browser answers with
|
|
958
|
+
# what it is now holding. That answer is what success means here - a
|
|
959
|
+
# command that exited 0 is not a page that arrived.
|
|
960
|
+
name=$(basename "$FILE")
|
|
961
|
+
timeout 20 agent-browser --cdp "$BRIDGE" tab new >/dev/null 2>&1
|
|
962
|
+
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' \
|
|
963
|
+
"$payload" \
|
|
964
|
+
| timeout 60 agent-browser --cdp "$BRIDGE" eval --stdin 2>/dev/null)
|
|
965
|
+
# Writing into the tab is only half of it. A tab created over CDP is
|
|
966
|
+
# created in the background, so it has to be brought forward or the page
|
|
967
|
+
# is real, correct, and never seen - which is exactly how this first went
|
|
968
|
+
# wrong. agent-browser carries Page.bringToFront on its tab switch, so
|
|
969
|
+
# switch to the tab just written into, which is the one it already has
|
|
970
|
+
# selected. Best effort: a page in a background tab still beats an error.
|
|
971
|
+
# The arrow marks the active tab, which is the one just written into.
|
|
972
|
+
# Taking the first line instead would raise whatever sits at index 0 -
|
|
973
|
+
# somebody else's tab, and the page still unseen.
|
|
974
|
+
idx=$(timeout 20 agent-browser --cdp "$BRIDGE" tab list 2>/dev/null \
|
|
975
|
+
| grep -m1 -- "$(printf '\342\206\222')" \
|
|
976
|
+
| sed -n 's/.*\[\([0-9][0-9]*\)\].*/\1/p')
|
|
977
|
+
[ -n "$idx" ] && timeout 20 agent-browser --cdp "$BRIDGE" tab "$idx" >/dev/null 2>&1
|
|
978
|
+
case "$landed" in
|
|
979
|
+
*"$name"*)
|
|
980
|
+
printf '%s is on screen in the browser on your machine\n' "$name" >&2
|
|
981
|
+
exit 0 ;;
|
|
982
|
+
esac
|
|
983
|
+
# Serving instead would print a link the laptop resolves to its own
|
|
984
|
+
# localhost, which is a wrong answer dressed as a working one.
|
|
985
|
+
echo "acstack-md: the bridge answered but is not holding the page." >&2
|
|
986
|
+
echo " the document goes to the tab the Playwriter extension is armed on." >&2
|
|
987
|
+
echo " check something is still armed: acstack-open --why" >&2
|
|
988
|
+
exit 1
|
|
989
|
+
;;
|
|
990
|
+
esac
|
|
991
|
+
# Every other route has a browser that can reach this machine's ports, or a
|
|
992
|
+
# terminal that can hand someone a link. Serving is already exactly that.
|
|
993
|
+
SERVE=1
|
|
994
|
+
fi
|
|
995
|
+
|
|
996
|
+
if [ "$SERVE" = "1" ] || [ "$HTML" = "1" ]; then
|
|
997
|
+
# One program, two ways out: --serve keeps answering with the document,
|
|
998
|
+
# --html hands it over once and stops. Same renderer either way, so the two
|
|
999
|
+
# cannot drift into disagreeing about what the page looks like.
|
|
1000
|
+
MODE=serve; NAME=acstack-md-serve
|
|
1001
|
+
if [ "$HTML" = "1" ]; then MODE=html; NAME=acstack-md-html; fi
|
|
1002
|
+
# A document bound for the opener on the laptop is handed over once, like
|
|
1003
|
+
# --html, but it does end up behind a server - the opener's. So it wants the
|
|
1004
|
+
# relative mermaid src that --html cannot use. Internal: set by the --browser
|
|
1005
|
+
# dispatch below, never by a caller.
|
|
1006
|
+
if [ "${ACSTACK_MD_OPENER:-0}" = "1" ] && [ "$HTML" = "1" ]; then MODE=opener; fi
|
|
1007
|
+
# exec -a names the process. Without it a running server is just "python3 -"
|
|
1008
|
+
# and there is nothing sensible to pkill.
|
|
1009
|
+
exec -a "$NAME" python3 - "$FILE" "$PORT" "$MODE" <<'PY'
|
|
1010
|
+
import html, http.server, pathlib, re, socketserver, sys
|
|
1011
|
+
try:
|
|
1012
|
+
import markdown
|
|
1013
|
+
except ImportError:
|
|
1014
|
+
sys.exit("acstack-md: needs the python markdown package: pip install --user markdown")
|
|
1015
|
+
|
|
1016
|
+
src, port, MODE = pathlib.Path(sys.argv[1]), int(sys.argv[2]), sys.argv[3]
|
|
1017
|
+
VENDOR = pathlib.Path.home() / ".local/share/acstack/mermaid.min.js"
|
|
1018
|
+
|
|
1019
|
+
CSS = """*{box-sizing:border-box}body{max-width:54rem;margin:0 auto;padding:2rem 1.25rem;
|
|
1020
|
+
font:16px/1.65 -apple-system,Segoe UI,system-ui,sans-serif;color:#1a1a1a;background:#fff}
|
|
1021
|
+
h1,h2,h3{line-height:1.25;margin:2rem 0 .75rem}h1{font-size:1.9rem}h2{font-size:1.45rem}
|
|
1022
|
+
code{font:14px/1.5 ui-monospace,Consolas,monospace;background:#f2f2f2;padding:.15em .35em;border-radius:3px}
|
|
1023
|
+
pre{background:#f7f7f7;padding:1rem;border-radius:6px;overflow-x:auto}pre code{background:none;padding:0}
|
|
1024
|
+
pre.mermaid{background:none;text-align:center}table{border-collapse:collapse;width:100%}
|
|
1025
|
+
th,td{border:1px solid #ddd;padding:.4rem .6rem;text-align:left}
|
|
1026
|
+
blockquote{margin:1rem 0;padding:.1rem 1rem;border-left:3px solid #ccc;color:#555}
|
|
1027
|
+
img{max-width:100%}
|
|
1028
|
+
@media(prefers-color-scheme:dark){body{background:#151515;color:#e6e6e6}
|
|
1029
|
+
code{background:#242424}pre{background:#1d1d1d}th,td{border-color:#333}
|
|
1030
|
+
blockquote{border-color:#444;color:#aaa}}"""
|
|
1031
|
+
|
|
1032
|
+
def render():
|
|
1033
|
+
body = markdown.markdown(src.read_text(),
|
|
1034
|
+
extensions=["fenced_code", "tables", "toc", "sane_lists"])
|
|
1035
|
+
# mermaid.js wants <pre class="mermaid">, not a highlighted code block.
|
|
1036
|
+
body = re.sub(r'<pre><code class="language-mermaid">(.*?)</code></pre>',
|
|
1037
|
+
lambda m: '<pre class="mermaid">' + html.unescape(m.group(1)) + "</pre>",
|
|
1038
|
+
body, flags=re.S)
|
|
1039
|
+
# --serve answers for /mermaid.js itself off the vendored copy. A document
|
|
1040
|
+
# standing on its own has no server behind it, so a relative src would be
|
|
1041
|
+
# fetched from whatever host the page ended up on and find nothing there -
|
|
1042
|
+
# it names the CDN instead. Inlining the vendored 3.5MB would make the page
|
|
1043
|
+
# offline-proof and far too big to push through a CDP payload, which is the
|
|
1044
|
+
# whole reason --html exists.
|
|
1045
|
+
# The opener serves the page from the laptop and answers /mermaid.js off
|
|
1046
|
+
# the copy this machine uploaded to it - falling back to the CDN itself if
|
|
1047
|
+
# it has none. So a relative src is right there too, and unlike --serve it
|
|
1048
|
+
# does not depend on this machine having vendored anything.
|
|
1049
|
+
js = ('<script src="/mermaid.js"></script>'
|
|
1050
|
+
if MODE == "opener" or (MODE == "serve" and VENDOR.exists()) else
|
|
1051
|
+
'<script src="https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js"></script>')
|
|
1052
|
+
return f"""<!doctype html><html><head><meta charset="utf-8">
|
|
1053
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
1054
|
+
<title>{html.escape(src.name)}</title><style>{CSS}</style></head><body>
|
|
1055
|
+
{body}
|
|
1056
|
+
{js}
|
|
1057
|
+
<script>mermaid.initialize({{startOnLoad:true,securityLevel:"loose",
|
|
1058
|
+
theme:matchMedia("(prefers-color-scheme:dark)").matches?"dark":"default"}});</script>
|
|
1059
|
+
</body></html>""".encode()
|
|
1060
|
+
|
|
1061
|
+
# Out before the port is ever looked at: a document is not a server, and a
|
|
1062
|
+
# caller that only wants the page must not be stopped by a busy 6419.
|
|
1063
|
+
if MODE in ("html", "opener"):
|
|
1064
|
+
sys.stdout.buffer.write(render())
|
|
1065
|
+
raise SystemExit
|
|
1066
|
+
|
|
1067
|
+
class H(http.server.BaseHTTPRequestHandler):
|
|
1068
|
+
def do_GET(self):
|
|
1069
|
+
if self.path == "/mermaid.js" and VENDOR.exists():
|
|
1070
|
+
body, ctype = VENDOR.read_bytes(), "application/javascript"
|
|
1071
|
+
else:
|
|
1072
|
+
body, ctype = render(), "text/html; charset=utf-8" # re-read: edits show on reload
|
|
1073
|
+
self.send_response(200)
|
|
1074
|
+
self.send_header("Content-Type", ctype)
|
|
1075
|
+
self.send_header("Content-Length", str(len(body)))
|
|
1076
|
+
self.send_header("Cache-Control", "no-store")
|
|
1077
|
+
self.end_headers()
|
|
1078
|
+
self.wfile.write(body)
|
|
1079
|
+
def log_message(self, *a): pass
|
|
1080
|
+
|
|
1081
|
+
socketserver.TCPServer.allow_reuse_address = True
|
|
1082
|
+
def holder(port):
|
|
1083
|
+
"""pid and command line of whatever is listening, best effort."""
|
|
1084
|
+
import subprocess
|
|
1085
|
+
try:
|
|
1086
|
+
out = subprocess.run(["ss", "-ltnpH", f"sport = :{port}"],
|
|
1087
|
+
capture_output=True, text=True, timeout=3).stdout
|
|
1088
|
+
pid = re.search(r"pid=(\d+)", out)
|
|
1089
|
+
if not pid:
|
|
1090
|
+
return None, None
|
|
1091
|
+
pid = pid.group(1)
|
|
1092
|
+
cmd = pathlib.Path(f"/proc/{pid}/cmdline").read_bytes().replace(b"\0", b" ").decode()
|
|
1093
|
+
return pid, cmd.strip()
|
|
1094
|
+
except Exception:
|
|
1095
|
+
return None, None
|
|
1096
|
+
|
|
1097
|
+
try:
|
|
1098
|
+
srv = socketserver.TCPServer(("127.0.0.1", port), H)
|
|
1099
|
+
except OSError as e:
|
|
1100
|
+
pid, cmd = holder(port)
|
|
1101
|
+
msg = [f"port {port} is busy: {e}"]
|
|
1102
|
+
if pid:
|
|
1103
|
+
mine = "acstack-md-serve" in cmd or "acstack-md" in cmd
|
|
1104
|
+
msg.append(f" held by pid {pid}: {cmd[:90]}")
|
|
1105
|
+
msg.append(f" stop it with: kill {pid}" if mine else
|
|
1106
|
+
f" not acstack-md. use another port: ACSTACK_MD_PORT=<n> acstack-md --serve ...")
|
|
1107
|
+
else:
|
|
1108
|
+
msg.append(" could not identify the holder. try: ss -ltnp | grep " + str(port))
|
|
1109
|
+
sys.exit("\n".join(msg))
|
|
1110
|
+
url = f"http://localhost:{port}"
|
|
1111
|
+
# OSC 8 makes it ctrl+clickable; the bare URL below covers terminals without it.
|
|
1112
|
+
sys.stderr.write(f"\033]8;;{url}\033\\{src.name}\033]8;;\033\\ {url}\n")
|
|
1113
|
+
sys.stderr.write(" reload the page after editing. ctrl+C to stop.\n")
|
|
1114
|
+
if not VENDOR.exists():
|
|
1115
|
+
sys.stderr.write(" note: mermaid.js not vendored, falling back to CDN\n")
|
|
1116
|
+
try: srv.serve_forever()
|
|
1117
|
+
except KeyboardInterrupt: pass
|
|
1118
|
+
PY
|
|
1119
|
+
fi
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
# Terminal render: swap each mermaid fence for its text drawing, then page it.
|
|
1123
|
+
ACSTACK_COLS="$COLS" ACSTACK_PAD="$PAD" ACSTACK_ONLY="$ONLY" ACSTACK_RUN="$$" \
|
|
1124
|
+
ACSTACK_PREVIEW="$PREVIEW" \
|
|
1125
|
+
python3 - "$FILE" <<'PY' > /tmp/acstack-md.$$.md 2>/dev/null
|
|
1126
|
+
import os, pathlib, re, shlex, shutil, subprocess, sys, tempfile
|
|
1127
|
+
text = pathlib.Path(sys.argv[1]).read_text()
|
|
1128
|
+
# "$HOME/..." rather than the absolute path: shorter, and unlike ~'/x y'
|
|
1129
|
+
# it expands correctly when the path contains spaces.
|
|
1130
|
+
_raw = sys.argv[1]
|
|
1131
|
+
_home = str(pathlib.Path.home())
|
|
1132
|
+
SELF = ('"$HOME' + _raw[len(_home):].replace('"', '\\"') + '"'
|
|
1133
|
+
if _raw.startswith(_home) else shlex.quote(_raw))
|
|
1134
|
+
have = shutil.which("mermaid-ascii")
|
|
1135
|
+
COLS = int(os.environ.get("ACSTACK_COLS") or 80)
|
|
1136
|
+
PAD = (os.environ.get("ACSTACK_PAD") or "").split()
|
|
1137
|
+
ONLY = int(os.environ.get("ACSTACK_ONLY") or 0)
|
|
1138
|
+
PREVIEW = os.environ.get("ACSTACK_PREVIEW") == "1"
|
|
1139
|
+
# glow indents and pads a fenced block, so the art has less room than the pane.
|
|
1140
|
+
BUDGET = max(20, COLS - 6)
|
|
1141
|
+
seen = 0
|
|
1142
|
+
|
|
1143
|
+
# mermaid-ascii understands `graph`/`flowchart` with [square] nodes. It draws
|
|
1144
|
+
# styling directives as if they were nodes, and leaks any other shape syntax
|
|
1145
|
+
# into the label, so both are normalised away before it sees the source.
|
|
1146
|
+
DROP = re.compile(r"^\s*(style|classDef|class|linkStyle|click|%%)\b")
|
|
1147
|
+
SHAPES = [
|
|
1148
|
+
(re.compile(r"(\w+)\{\{(.+?)\}\}"), r"\1[\2]"), # {{hexagon}}
|
|
1149
|
+
(re.compile(r"(\w+)\(\((.+?)\)\)"), r"\1[\2]"), # ((circle))
|
|
1150
|
+
(re.compile(r"(\w+)\(\[(.+?)\]\)"), r"\1[\2]"), # ([stadium])
|
|
1151
|
+
(re.compile(r"(\w+)\[\((.+?)\)\]"), r"\1[\2]"), # [(database)]
|
|
1152
|
+
(re.compile(r"(\w+)\{(.+?)\}"), r"\1[\2]"), # {decision}
|
|
1153
|
+
(re.compile(r"(\w+)\((.+?)\)"), r"\1[\2]"), # (rounded)
|
|
1154
|
+
]
|
|
1155
|
+
|
|
1156
|
+
def to_ascii(src):
|
|
1157
|
+
if not have:
|
|
1158
|
+
return None, "mermaid-ascii not installed"
|
|
1159
|
+
body = "\n".join(l for l in src.splitlines() if not DROP.match(l))
|
|
1160
|
+
for pat, rep in SHAPES:
|
|
1161
|
+
body = pat.sub(rep, body)
|
|
1162
|
+
if not re.match(r"\s*(graph|flowchart|sequenceDiagram)\b", body):
|
|
1163
|
+
kind = (body.strip().split(None, 1) or ["?"])[0]
|
|
1164
|
+
return None, f"{kind} diagrams are not supported by mermaid-ascii"
|
|
1165
|
+
with tempfile.NamedTemporaryFile("w", suffix=".mmd", delete=False) as f:
|
|
1166
|
+
f.write(body); path = f.name
|
|
1167
|
+
# mermaid-ascii reads a file, not stdin. A preview redraws on every cursor
|
|
1168
|
+
# move, so a file left behind here fills /tmp at cursor speed.
|
|
1169
|
+
try:
|
|
1170
|
+
r = subprocess.run(["mermaid-ascii", "--file", path] + PAD,
|
|
1171
|
+
capture_output=True, text=True)
|
|
1172
|
+
finally:
|
|
1173
|
+
os.unlink(path)
|
|
1174
|
+
if r.returncode != 0 or not r.stdout.strip():
|
|
1175
|
+
return None, (r.stderr.strip().splitlines() or ["could not draw it"])[0]
|
|
1176
|
+
return r.stdout.rstrip("\n"), None
|
|
1177
|
+
|
|
1178
|
+
def repl(m):
|
|
1179
|
+
global seen
|
|
1180
|
+
seen += 1
|
|
1181
|
+
n = seen
|
|
1182
|
+
art, err = to_ascii(m.group(1))
|
|
1183
|
+
if not art:
|
|
1184
|
+
# Keep the source visible rather than swallowing the diagram.
|
|
1185
|
+
return (f"\n```\nDiagram {n} not drawn: {err}\n```\n\n"
|
|
1186
|
+
f"```\n{m.group(1).rstrip()}\n```")
|
|
1187
|
+
lines = art.splitlines()
|
|
1188
|
+
width = max((len(l) for l in lines), default=0)
|
|
1189
|
+
if ONLY:
|
|
1190
|
+
# Raw, unwrapped, for a pager that can scroll sideways.
|
|
1191
|
+
return "\0DIAGRAM%d\0%s\0" % (n, art) if n == ONLY else ""
|
|
1192
|
+
if width > BUDGET:
|
|
1193
|
+
# glow wraps whatever it renders, and wrapped box-drawing art is
|
|
1194
|
+
# noise. Emit a marker instead and splice the raw art back in after
|
|
1195
|
+
# glow has run, so prose wraps to the pane and the diagram does not.
|
|
1196
|
+
run = os.environ.get("ACSTACK_RUN") or str(os.getpid())
|
|
1197
|
+
# /tmp, not gettempdir(): the shell trap and the splicer both look
|
|
1198
|
+
# there, and a stray $TMPDIR would hide the art from either.
|
|
1199
|
+
art_path = pathlib.Path(f"/tmp/acstack-md-art.{run}.{n}")
|
|
1200
|
+
art_path.write_text(art + "\n")
|
|
1201
|
+
# The arrow keys belong to the pager. A preview pane has none, and
|
|
1202
|
+
# opening the file is what draws the diagram there.
|
|
1203
|
+
hint = "open it full width" if PREVIEW else "pans sideways with the arrow keys"
|
|
1204
|
+
return f"\n```\n\x00ACSTACKART{n}\x00 {width} cols, {hint}\n```"
|
|
1205
|
+
return "```\n" + art + "\n```"
|
|
1206
|
+
|
|
1207
|
+
out = re.sub(r"```mermaid\n(.*?)```", repl, text, flags=re.S)
|
|
1208
|
+
if ONLY:
|
|
1209
|
+
hit = re.search(r"\0DIAGRAM(\d+)\0(.*?)\0", out, re.S)
|
|
1210
|
+
sys.stdout.write(hit.group(2) if hit else f"no diagram {ONLY} in this file\n")
|
|
1211
|
+
else:
|
|
1212
|
+
sys.stdout.write(out)
|
|
1213
|
+
PY
|
|
1214
|
+
|
|
1215
|
+
if [ "$ONLY" != "0" ]; then
|
|
1216
|
+
# -S chops instead of wrapping: arrow keys scroll sideways.
|
|
1217
|
+
emit /tmp/acstack-md.$$.md less -SR
|
|
1218
|
+
elif command -v glow >/dev/null 2>&1; then
|
|
1219
|
+
# glow drops all styling when stdout is not a terminal, and its output has
|
|
1220
|
+
# to be captured to splice the diagrams into, so it runs under a pty.
|
|
1221
|
+
ACSTACK_COLS="$COLS" ACSTACK_STYLE="${ACSTACK_MD_STYLE:-}" \
|
|
1222
|
+
python3 - /tmp/acstack-md.$$.md /tmp/acstack-md-out.$$.md <<'PY'
|
|
1223
|
+
import fcntl, os, pathlib, pty, re, struct, subprocess, sys, termios
|
|
1224
|
+
src, dst = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
|
|
1225
|
+
cols = int(os.environ.get("ACSTACK_COLS") or 80)
|
|
1226
|
+
# Leave glow on "auto": under a pty it picks its full palette. Pinning a
|
|
1227
|
+
# named style here gives a noticeably flatter one.
|
|
1228
|
+
style = os.environ.get("ACSTACK_STYLE") or ""
|
|
1229
|
+
|
|
1230
|
+
cmd = ["glow", "-w", str(cols - 2)] + (["--style", style] if style else []) + [str(src)]
|
|
1231
|
+
master, slave = pty.openpty()
|
|
1232
|
+
# glow reads the width from the pty, not just -w, so set it to match.
|
|
1233
|
+
fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack("HHHH", 200, min(cols, 65535), 0, 0))
|
|
1234
|
+
proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=slave, stderr=subprocess.DEVNULL)
|
|
1235
|
+
os.close(slave)
|
|
1236
|
+
chunks = []
|
|
1237
|
+
while True:
|
|
1238
|
+
try:
|
|
1239
|
+
b = os.read(master, 65536)
|
|
1240
|
+
except OSError:
|
|
1241
|
+
break
|
|
1242
|
+
if not b:
|
|
1243
|
+
break
|
|
1244
|
+
chunks.append(b)
|
|
1245
|
+
os.close(master); proc.wait()
|
|
1246
|
+
raw = b"".join(chunks)
|
|
1247
|
+
text = raw.decode("utf-8", "replace").replace("\r\n", "\n")
|
|
1248
|
+
# A pty makes glow probe the terminal for its background colour. Those replies
|
|
1249
|
+
# are meant for a real terminal, not a file, so drop them.
|
|
1250
|
+
text = re.sub(r"\x1b\][01][01];\?(\x07|\x1b\\)", "", text)
|
|
1251
|
+
text = re.sub(r"\x1b\[6n", "", text)
|
|
1252
|
+
dst.write_text(text)
|
|
1253
|
+
PY
|
|
1254
|
+
splice /tmp/acstack-md-out.$$.md
|
|
1255
|
+
emit /tmp/acstack-md-out.$$.md ${PAGER:-less -SR}
|
|
1256
|
+
else
|
|
1257
|
+
splice /tmp/acstack-md.$$.md
|
|
1258
|
+
emit /tmp/acstack-md.$$.md ${PAGER:-less -R}
|
|
1259
|
+
fi
|
|
1260
|
+
H
|
|
1261
|
+
chmod +x "$BIN/acstack-md"
|
|
1262
|
+
|
|
1263
|
+
cat > "$BIN/acstack-view" <<'H'
|
|
1264
|
+
#!/usr/bin/env bash
|
|
1265
|
+
# View one file, picked by what the file is.
|
|
1266
|
+
#
|
|
1267
|
+
# acstack-view README.md rendered markdown, mermaid diagrams included
|
|
1268
|
+
# acstack-view src/main.rs yazi, opened with the cursor on that file
|
|
1269
|
+
#
|
|
1270
|
+
# tuicr's <leader>v hands us the file under its cursor. Everything this
|
|
1271
|
+
# dispatches to is a terminal program, so `q` hands the screen back to tuicr
|
|
1272
|
+
# the same way a terminal editor does - which is the whole reason the choice
|
|
1273
|
+
# lives in a script rather than in tuicr: changing it is an edit, not a
|
|
1274
|
+
# rebuild.
|
|
1275
|
+
# Installed by acstack.
|
|
1276
|
+
set -uo pipefail
|
|
1277
|
+
|
|
1278
|
+
FILE="${1:-}"
|
|
1279
|
+
[ -n "$FILE" ] || { echo "usage: acstack-view <file>" >&2; exit 2; }
|
|
1280
|
+
[ -e "$FILE" ] || { echo "acstack-view: no such file: $FILE" >&2; exit 1; }
|
|
1281
|
+
|
|
1282
|
+
# Markdown is worth rendering rather than listing: acstack-md draws mermaid
|
|
1283
|
+
# diagrams as text, and offers --serve for a browser when a diagram needs to
|
|
1284
|
+
# be rendered properly.
|
|
1285
|
+
case "$FILE" in
|
|
1286
|
+
*.md|*.MD|*.markdown|*.Markdown|*.mdown|*.mkd)
|
|
1287
|
+
command -v acstack-md >/dev/null 2>&1 && exec acstack-md "$FILE"
|
|
1288
|
+
;;
|
|
1289
|
+
esac
|
|
1290
|
+
|
|
1291
|
+
# yazi opens on the file's own directory with the cursor on it, so the preview
|
|
1292
|
+
# pane shows the file and the tree is right there to move around in.
|
|
1293
|
+
command -v yazi >/dev/null 2>&1 && exec yazi "$FILE"
|
|
1294
|
+
|
|
1295
|
+
# Neither installed: a pager still beats nothing, and -R keeps any colour.
|
|
1296
|
+
exec ${PAGER:-less -R} "$FILE"
|
|
1297
|
+
H
|
|
1298
|
+
chmod +x "$BIN/acstack-view"
|
|
1299
|
+
|
|
1300
|
+
cat > "$BIN/acstack-popup" <<'H'
|
|
1301
|
+
#!/bin/sh
|
|
1302
|
+
# Run a herdr popup's command with this folder first on PATH.
|
|
1303
|
+
#
|
|
1304
|
+
# acstack-popup <command> [args...]
|
|
1305
|
+
#
|
|
1306
|
+
# herdr runs a popup with its server's environment, not a login shell. A
|
|
1307
|
+
# server started by `herdr --remote` has the sshd default PATH, which lacks
|
|
1308
|
+
# this folder, so a bare popup target exits 127. apply calls this helper by
|
|
1309
|
+
# absolute path, and it finds its folder from $0. herdr and harlequin always
|
|
1310
|
+
# link into ~/.local/bin, so that goes on PATH too. The target and every tool
|
|
1311
|
+
# it starts then resolve whatever PATH the server has. Installed by acstack.
|
|
1312
|
+
PATH="$(dirname "$0"):$HOME/.local/bin:$PATH"; export PATH
|
|
1313
|
+
exec "$@"
|
|
1314
|
+
H
|
|
1315
|
+
chmod +x "$BIN/acstack-popup"
|
|
1316
|
+
|
|
1317
|
+
cat > "$BIN/acstack-herdr-tab" <<'H'
|
|
1318
|
+
#!/usr/bin/env bash
|
|
1319
|
+
# Run a command in a new herdr tab, beside the pane the key was pressed in.
|
|
1320
|
+
#
|
|
1321
|
+
# acstack-herdr-tab <label> <command> [args...]
|
|
1322
|
+
#
|
|
1323
|
+
# herdr has no type = "tab" custom command, and its tab.create API launches a
|
|
1324
|
+
# shell rather than a command, so the tab is created first and the command
|
|
1325
|
+
# sent into it with `herdr pane run`.
|
|
1326
|
+
#
|
|
1327
|
+
# The target comes from `herdr pane current`, not from HERDR_* variables: a
|
|
1328
|
+
# type = "shell" binding runs detached with none of them set, and reading the
|
|
1329
|
+
# environment sent every one of these commands off to run with no terminal.
|
|
1330
|
+
# Asked without that environment, herdr reports the focused pane, which is
|
|
1331
|
+
# exactly the one the key was pressed in. Installed by acstack.
|
|
1332
|
+
set -uo pipefail
|
|
1333
|
+
|
|
1334
|
+
label="${1:?usage: acstack-herdr-tab <label> <command> [args...]}"; shift
|
|
1335
|
+
[ $# -gt 0 ] || { echo "acstack-herdr-tab: no command given" >&2; exit 1; }
|
|
1336
|
+
|
|
1337
|
+
command -v herdr >/dev/null 2>&1 || exec "$@"
|
|
1338
|
+
|
|
1339
|
+
cur=$(herdr pane current 2>/dev/null) || exec "$@"
|
|
1340
|
+
read -r ws cwd <<<"$(printf '%s' "$cur" | python3 -c '
|
|
1341
|
+
import json, sys
|
|
1342
|
+
p = json.load(sys.stdin).get("result", {}).get("pane", {})
|
|
1343
|
+
print(p.get("workspace_id") or "", p.get("foreground_cwd") or p.get("cwd") or "")
|
|
1344
|
+
' 2>/dev/null)"
|
|
1345
|
+
[ -n "${ws:-}" ] || exec "$@"
|
|
1346
|
+
|
|
1347
|
+
resp=$(herdr tab create --workspace "$ws" ${cwd:+--cwd "$cwd"} --label "$label" --focus 2>/dev/null) \
|
|
1348
|
+
|| exec "$@"
|
|
1349
|
+
pane=$(printf '%s' "$resp" | python3 -c \
|
|
1350
|
+
'import json,sys; print(json.load(sys.stdin)["result"]["root_pane"]["pane_id"])' 2>/dev/null)
|
|
1351
|
+
[ -n "$pane" ] || exec "$@"
|
|
1352
|
+
|
|
1353
|
+
# herdr hosts a type = "shell" command in a real pane for as long as the
|
|
1354
|
+
# process lives, so anything slow here shows up as a stray terminal in the
|
|
1355
|
+
# current tab. Hand the wait to a detached child and return immediately: the
|
|
1356
|
+
# host pane then lasts only as long as the tab.create call.
|
|
1357
|
+
#
|
|
1358
|
+
# The settle is because the new tab's shell may not have drawn its prompt yet.
|
|
1359
|
+
# The pty buffers input, so it is belt and braces, not a correctness need.
|
|
1360
|
+
#
|
|
1361
|
+
# `herdr pane run` hands the string to a shell, so each argument is quoted on
|
|
1362
|
+
# the way in. A bare "$*" loses any file path holding a space.
|
|
1363
|
+
printf -v cmd '%q ' "$@"
|
|
1364
|
+
( sleep 0.3; herdr pane run "$pane" "$cmd" >/dev/null 2>&1 ) </dev/null >/dev/null 2>&1 &
|
|
1365
|
+
disown 2>/dev/null || true
|
|
1366
|
+
exit 0
|
|
1367
|
+
H
|
|
1368
|
+
chmod +x "$BIN/acstack-herdr-tab"
|
|
1369
|
+
|
|
1370
|
+
cat > "$BIN/acstack-edit" <<'H'
|
|
1371
|
+
#!/usr/bin/env bash
|
|
1372
|
+
# Open one file in its own herdr tab, for tools that shell out to $EDITOR.
|
|
1373
|
+
#
|
|
1374
|
+
# acstack-edit <file>
|
|
1375
|
+
#
|
|
1376
|
+
# diffnav's "o" key runs $EDITOR inside diffnav's own pane. For alt+f and alt+x
|
|
1377
|
+
# that pane is a herdr popup, and a popup cannot host a second full-screen app,
|
|
1378
|
+
# so the editor never draws and the key looks dead. This gives the file its own
|
|
1379
|
+
# herdr tab and returns at once, which leaves the diff untouched behind the
|
|
1380
|
+
# popup. Close the popup to reach the tab.
|
|
1381
|
+
#
|
|
1382
|
+
# ACSTACK_EDIT_EDITOR names the real editor: whatever set this script as
|
|
1383
|
+
# $EDITOR has already taken that variable.
|
|
1384
|
+
#
|
|
1385
|
+
# acstack-herdr-tab does the work. It finds the pane behind the popup, creates
|
|
1386
|
+
# the tab, and runs the editor in it, and it runs the editor in place when
|
|
1387
|
+
# there is no herdr to ask. Installed by acstack.
|
|
1388
|
+
set -uo pipefail
|
|
1389
|
+
|
|
1390
|
+
ED="${ACSTACK_EDIT_EDITOR:-nvim}"
|
|
1391
|
+
case "$ED" in *acstack-edit) ED=nvim ;; esac # never call itself
|
|
1392
|
+
FILE="${1:?usage: acstack-edit <file>}"
|
|
1393
|
+
|
|
1394
|
+
command -v acstack-herdr-tab >/dev/null 2>&1 || exec "$ED" "$FILE"
|
|
1395
|
+
exec acstack-herdr-tab "$(basename "$FILE")" "$ED" "$FILE"
|
|
1396
|
+
H
|
|
1397
|
+
chmod +x "$BIN/acstack-edit"
|
|
1398
|
+
|
|
1399
|
+
cat > "$BIN/acstack-herdr-renumber" <<'H'
|
|
1400
|
+
#!/usr/bin/env python3
|
|
1401
|
+
"""Make the herdr sidebar numbers match prefix+1..9.
|
|
1402
|
+
|
|
1403
|
+
herdr draws a workspace's position in its stored list, but prefix+N targets the
|
|
1404
|
+
row's position in the grouped sidebar, where worktrees sit indented under their
|
|
1405
|
+
parent repo. The two agree until you create or tear down a worktree: new ones
|
|
1406
|
+
append to the end of the list but appear mid-sidebar under their parent, so the
|
|
1407
|
+
numbers you read stop being the numbers you press.
|
|
1408
|
+
|
|
1409
|
+
This rewrites the stored order to match the grouped order, so both agree again.
|
|
1410
|
+
Run it after adding or removing a worktree. Safe to run repeatedly. Installed
|
|
1411
|
+
by acstack.
|
|
1412
|
+
"""
|
|
1413
|
+
import json
|
|
1414
|
+
import os
|
|
1415
|
+
import socket
|
|
1416
|
+
import sys
|
|
1417
|
+
|
|
1418
|
+
|
|
1419
|
+
def socket_path():
|
|
1420
|
+
override = os.environ.get("HERDR_SOCKET_PATH")
|
|
1421
|
+
if override:
|
|
1422
|
+
return override
|
|
1423
|
+
config = os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config")
|
|
1424
|
+
return os.path.join(config, "herdr", "herdr.sock")
|
|
1425
|
+
|
|
1426
|
+
|
|
1427
|
+
def call(method, params=None):
|
|
1428
|
+
try:
|
|
1429
|
+
conn = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
1430
|
+
conn.connect(socket_path())
|
|
1431
|
+
except OSError as err:
|
|
1432
|
+
sys.exit(f"acstack-herdr-renumber: no herdr server at {socket_path()} ({err})")
|
|
1433
|
+
with conn:
|
|
1434
|
+
conn.sendall((json.dumps({"id": "renumber", "method": method,
|
|
1435
|
+
"params": params or {}}) + "\n").encode())
|
|
1436
|
+
buf = b""
|
|
1437
|
+
while not buf.endswith(b"\n"):
|
|
1438
|
+
chunk = conn.recv(65536)
|
|
1439
|
+
if not chunk:
|
|
1440
|
+
break
|
|
1441
|
+
buf += chunk
|
|
1442
|
+
lines = buf.decode().strip().splitlines()
|
|
1443
|
+
if not lines:
|
|
1444
|
+
sys.exit(f"acstack-herdr-renumber: herdr closed the connection during {method}")
|
|
1445
|
+
reply = json.loads(lines[0])
|
|
1446
|
+
if "error" in reply:
|
|
1447
|
+
sys.exit(f"acstack-herdr-renumber: {method} failed: {reply['error']}")
|
|
1448
|
+
return reply
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
def workspaces():
|
|
1452
|
+
return call("workspace.list")["result"]["workspaces"]
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def grouped_order(spaces):
|
|
1456
|
+
"""The order the sidebar draws: every repo's rows contiguous, the repo
|
|
1457
|
+
checkout above the worktrees indented under it, stored order within each
|
|
1458
|
+
of those two parts. A group sits where its repo checkout sits, not where
|
|
1459
|
+
its first row happens to sit, so a worktree cannot drag its parent up."""
|
|
1460
|
+
groups, anchors = {}, {}
|
|
1461
|
+
for position, space in enumerate(spaces):
|
|
1462
|
+
tree = space.get("worktree") or {}
|
|
1463
|
+
root = tree.get("repo_root") or space["workspace_id"]
|
|
1464
|
+
groups.setdefault(root, []).append((bool(tree.get("is_linked_worktree")),
|
|
1465
|
+
position, space["workspace_id"]))
|
|
1466
|
+
if not tree.get("is_linked_worktree") and root not in anchors:
|
|
1467
|
+
anchors[root] = position
|
|
1468
|
+
# A group of nothing but worktrees, its repo checkout never opened, anchors
|
|
1469
|
+
# on its first row instead.
|
|
1470
|
+
for root, members in groups.items():
|
|
1471
|
+
anchors.setdefault(root, min(position for _, position, _ in members))
|
|
1472
|
+
order = []
|
|
1473
|
+
for root in sorted(groups, key=lambda key: anchors[key]):
|
|
1474
|
+
order.extend(wid for _, _, wid in sorted(groups[root]))
|
|
1475
|
+
return order
|
|
1476
|
+
|
|
1477
|
+
|
|
1478
|
+
def main():
|
|
1479
|
+
before = workspaces()
|
|
1480
|
+
target = grouped_order(before)
|
|
1481
|
+
if [space["workspace_id"] for space in before] == target:
|
|
1482
|
+
print("already aligned")
|
|
1483
|
+
else:
|
|
1484
|
+
for index, workspace_id in enumerate(target):
|
|
1485
|
+
call("workspace.move", {"workspace_id": workspace_id, "insert_index": index})
|
|
1486
|
+
for space in workspaces():
|
|
1487
|
+
indent = " " if (space.get("worktree") or {}).get("is_linked_worktree") else ""
|
|
1488
|
+
print(f"{space['number']:>3} {indent}{space['label']}")
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
if __name__ == "__main__":
|
|
1492
|
+
main()
|
|
1493
|
+
H
|
|
1494
|
+
chmod +x "$BIN/acstack-herdr-renumber"
|
|
1495
|
+
|
|
1496
|
+
cat > "$BIN/acstack-clip" <<'H'
|
|
1497
|
+
#!/usr/bin/env bash
|
|
1498
|
+
# Put text on the clipboard of the machine your terminal is on.
|
|
1499
|
+
#
|
|
1500
|
+
# acstack-clip "text" copy the arguments
|
|
1501
|
+
# ... | acstack-clip copy stdin
|
|
1502
|
+
# acstack-clip --out print what was copied last
|
|
1503
|
+
#
|
|
1504
|
+
# A VM reached over SSH has no display and no clipboard of its own, so
|
|
1505
|
+
# xclip and friends have nothing to talk to. OSC 52 is the escape sequence
|
|
1506
|
+
# that asks the terminal at the far end - Windows Terminal, iTerm2, kitty -
|
|
1507
|
+
# to put text on its own clipboard. It is only bytes in the terminal stream,
|
|
1508
|
+
# so it crosses SSH for free, and herdr forwards it from a pane to whatever
|
|
1509
|
+
# terminal is hosting it.
|
|
1510
|
+
#
|
|
1511
|
+
# Reading back is not symmetrical. Windows Terminal refuses OSC 52 reads on
|
|
1512
|
+
# purpose, so --out replays a local cache instead. Installed by acstack.
|
|
1513
|
+
set -uo pipefail
|
|
1514
|
+
|
|
1515
|
+
CACHE="${XDG_CACHE_HOME:-$HOME/.cache}/acstack/clipboard"
|
|
1516
|
+
|
|
1517
|
+
# A real clipboard beats an escape sequence when one exists. Anything sitting
|
|
1518
|
+
# in this script's own directory is skipped, so the xclip shim installed
|
|
1519
|
+
# beside it can never call back into here in a loop.
|
|
1520
|
+
native() { # native <tool>; prints the path of a real one, if any
|
|
1521
|
+
local tool="$1" self candidate
|
|
1522
|
+
self="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
|
|
1523
|
+
while IFS= read -r candidate; do
|
|
1524
|
+
[ "$(cd "$(dirname "$candidate")" 2>/dev/null && pwd)" = "$self" ] && continue
|
|
1525
|
+
printf '%s' "$candidate"; return 0
|
|
1526
|
+
done < <(type -ap "$tool" 2>/dev/null)
|
|
1527
|
+
return 1
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
has_display() { [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; }
|
|
1531
|
+
|
|
1532
|
+
if [ "${1:-}" = "--out" ] || [ "${1:-}" = "-o" ]; then
|
|
1533
|
+
if has_display; then
|
|
1534
|
+
for tool in wl-paste xclip xsel; do
|
|
1535
|
+
real="$(native "$tool")" || continue
|
|
1536
|
+
case "$tool" in
|
|
1537
|
+
wl-paste) exec "$real" --no-newline ;;
|
|
1538
|
+
xclip) exec "$real" -out -selection clipboard ;;
|
|
1539
|
+
xsel) exec "$real" --output --clipboard ;;
|
|
1540
|
+
esac
|
|
1541
|
+
done
|
|
1542
|
+
fi
|
|
1543
|
+
[ -f "$CACHE" ] || { echo "acstack-clip: nothing copied yet" >&2; exit 1; }
|
|
1544
|
+
cat "$CACHE"
|
|
1545
|
+
exit 0
|
|
1546
|
+
fi
|
|
1547
|
+
|
|
1548
|
+
if [ $# -gt 0 ]; then text="$*"; else text="$(cat)"; fi
|
|
1549
|
+
|
|
1550
|
+
mkdir -p "$(dirname "$CACHE")"
|
|
1551
|
+
printf '%s' "$text" > "$CACHE"
|
|
1552
|
+
chmod 600 "$CACHE" 2>/dev/null
|
|
1553
|
+
|
|
1554
|
+
if has_display; then
|
|
1555
|
+
for tool in wl-copy xclip xsel; do
|
|
1556
|
+
real="$(native "$tool")" || continue
|
|
1557
|
+
case "$tool" in
|
|
1558
|
+
wl-copy) printf '%s' "$text" | "$real" && exit 0 ;;
|
|
1559
|
+
xclip) printf '%s' "$text" | "$real" -in -selection clipboard && exit 0 ;;
|
|
1560
|
+
xsel) printf '%s' "$text" | "$real" --input --clipboard && exit 0 ;;
|
|
1561
|
+
esac
|
|
1562
|
+
done
|
|
1563
|
+
fi
|
|
1564
|
+
|
|
1565
|
+
b64="$(printf '%s' "$text" | base64 | tr -d '\n')"
|
|
1566
|
+
# 74994 bytes is the ceiling tmux puts on an OSC 52 payload, and the lowest
|
|
1567
|
+
# of anything in this path. Past it the sequence is dropped without a word,
|
|
1568
|
+
# so say so rather than report a copy that did not happen.
|
|
1569
|
+
if [ ${#b64} -gt 74994 ]; then
|
|
1570
|
+
echo "acstack-clip: too large for the terminal clipboard (${#b64} bytes encoded)" >&2
|
|
1571
|
+
exit 1
|
|
1572
|
+
fi
|
|
1573
|
+
|
|
1574
|
+
esc=$'\033]52;c;'"$b64"$'\a'
|
|
1575
|
+
# tmux drops escape sequences it does not recognise unless they are wrapped
|
|
1576
|
+
# for passthrough. herdr needs no wrapping - it forwards OSC 52 itself.
|
|
1577
|
+
if [ -n "${TMUX:-}" ]; then
|
|
1578
|
+
esc=$'\033Ptmux;'"${esc//$'\033'/$'\033\033'}"$'\033\\'
|
|
1579
|
+
fi
|
|
1580
|
+
|
|
1581
|
+
# The controlling terminal, not stdout. Callers are usually TUIs that have
|
|
1582
|
+
# redirected both streams, and OSC 52 has to reach the terminal itself.
|
|
1583
|
+
if { printf '%s' "$esc" > /dev/tty; } 2>/dev/null; then exit 0; fi
|
|
1584
|
+
printf '%s' "$esc" >&2
|
|
1585
|
+
H
|
|
1586
|
+
chmod +x "$BIN/acstack-clip"
|
|
1587
|
+
|
|
1588
|
+
cat > "$BIN/acstack-open" <<'H'
|
|
1589
|
+
#!/usr/bin/env bash
|
|
1590
|
+
# Open a URL from a machine that has no browser of its own.
|
|
1591
|
+
#
|
|
1592
|
+
# acstack-open https://github.com/owner/repo/pull/1
|
|
1593
|
+
# acstack-open --why which route this machine will take, without taking it
|
|
1594
|
+
#
|
|
1595
|
+
# gh-dash binds o to this. It is bound as a keybinding rather than left to
|
|
1596
|
+
# $BROWSER because gh-dash runs $BROWSER with its output discarded and the
|
|
1597
|
+
# dashboard still drawn: a route that needs to say anything, or to hand you a
|
|
1598
|
+
# link to click, has nowhere to put it. As a keybinding gh-dash steps aside
|
|
1599
|
+
# and gives us the terminal.
|
|
1600
|
+
#
|
|
1601
|
+
# In order: an explicit override, a desktop on this machine, WSL, the opener
|
|
1602
|
+
# service on the laptop, the Playwriter bridge to Chrome on the laptop, and
|
|
1603
|
+
# last a link you can click.
|
|
1604
|
+
# Installed by acstack.
|
|
1605
|
+
set -uo pipefail
|
|
1606
|
+
|
|
1607
|
+
LOG="${XDG_CACHE_HOME:-$HOME/.cache}/acstack/open.log"
|
|
1608
|
+
# An ambient $AGENT_BROWSER_CDP is a hint, never the thing we rely on. See
|
|
1609
|
+
# the bridge branch below for what happened when it was.
|
|
1610
|
+
BRIDGE="${ACSTACK_BRIDGE_URL:-${AGENT_BROWSER_CDP:-http://127.0.0.1:19988}}"
|
|
1611
|
+
OPENER="${ACSTACK_OPENER_URL:-http://127.0.0.1:19989}"
|
|
1612
|
+
# The opener refuses anything without this, so its absence is the same as the
|
|
1613
|
+
# opener being down - which is exactly how the route probe treats it.
|
|
1614
|
+
OPENER_TOKEN="${XDG_CONFIG_HOME:-$HOME/.config}/acstack/opener.token"
|
|
1615
|
+
|
|
1616
|
+
WHY=0
|
|
1617
|
+
[ "${1:-}" = "--why" ] && { WHY=1; shift; }
|
|
1618
|
+
URL="${1:-}"
|
|
1619
|
+
[ -n "$URL" ] || [ "$WHY" = 1 ] \
|
|
1620
|
+
|| { echo "usage: acstack-open [--why] <url>" >&2; exit 2; }
|
|
1621
|
+
|
|
1622
|
+
log() { # the only record of what happened when the caller discards output
|
|
1623
|
+
mkdir -p "$(dirname "$LOG")"
|
|
1624
|
+
printf '%s %s\n' "$(date -Is)" "$*" >> "$LOG"
|
|
1625
|
+
if [ "$(wc -l < "$LOG" 2>/dev/null || echo 0)" -gt 400 ]; then
|
|
1626
|
+
tail -n 200 "$LOG" > "$LOG.trim" && mv "$LOG.trim" "$LOG"
|
|
1627
|
+
fi
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
note() { # say something a suspended TUI cannot print for us
|
|
1631
|
+
command -v herdr >/dev/null 2>&1 \
|
|
1632
|
+
&& herdr notification show "$1" --body "$2" >/dev/null 2>&1
|
|
1633
|
+
return 0
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
# Not [ -t 1 ]: route() runs inside a command substitution for --why, which
|
|
1637
|
+
# makes stdout a pipe even when the terminal is right there.
|
|
1638
|
+
has_tty() { { : > /dev/tty; } 2>/dev/null; }
|
|
1639
|
+
|
|
1640
|
+
# The relay answering on its HTTP port is not the same as the browser being
|
|
1641
|
+
# reachable through it. agent-browser quietly launches a headless Chrome on
|
|
1642
|
+
# this machine when it cannot attach, and that Chrome navigates happily and
|
|
1643
|
+
# shows nobody anything - which is how o came to report a page it had opened
|
|
1644
|
+
# where no one could see it. So ask what is actually on the far end.
|
|
1645
|
+
#
|
|
1646
|
+
# /json/list is the Playwriter extension's own answer to that question. The
|
|
1647
|
+
# extension attaches per tab, so `[]` means it is armed on nothing and there
|
|
1648
|
+
# is no browser to drive, however healthy the tunnel underneath looks.
|
|
1649
|
+
#
|
|
1650
|
+
# This used to gate on $HOME/.agent-browser/default.sock existing first, on
|
|
1651
|
+
# the reasoning that probing starts a browser as a side effect. It does not:
|
|
1652
|
+
# curl starts nothing. What that gate did do was make the bridge unreachable,
|
|
1653
|
+
# because nothing on this machine creates that socket until an agent-browser
|
|
1654
|
+
# session is already running - so a perfectly healthy bridge still fell
|
|
1655
|
+
# through to the link route. Asking the relay is both safer and correct.
|
|
1656
|
+
bridge_attached() {
|
|
1657
|
+
[ "${ACSTACK_OPEN_BRIDGE:-auto}" != "off" ] || return 1
|
|
1658
|
+
# agent-browser has to exist to take the route, but is never run to decide
|
|
1659
|
+
# it: deciding must cost one round trip, not a browser launch.
|
|
1660
|
+
command -v agent-browser >/dev/null 2>&1 || return 1
|
|
1661
|
+
# Seconds, not tens of them: the tunnel stays bound on this side after the
|
|
1662
|
+
# relay at the far end stops answering, so an unhealthy bridge connects and
|
|
1663
|
+
# then hangs rather than refusing. Every one of those seconds is spent in
|
|
1664
|
+
# front of someone who just pressed a key.
|
|
1665
|
+
local targets
|
|
1666
|
+
targets=$(curl -fsS --connect-timeout 1 -m 2 "$BRIDGE/json/list" 2>/dev/null) || return 1
|
|
1667
|
+
# Whitespace-only and [] both mean armed on no tab.
|
|
1668
|
+
case "$(printf '%s' "$targets" | tr -d '[:space:]')" in
|
|
1669
|
+
''|'[]') return 1 ;;
|
|
1670
|
+
esac
|
|
1671
|
+
return 0
|
|
1672
|
+
}
|
|
1673
|
+
|
|
1674
|
+
# Same discipline as bridge_attached: ask the far end rather than believing
|
|
1675
|
+
# anything local. A token on disk proves nothing about a service being up, and
|
|
1676
|
+
# a service being up proves nothing without the token it will demand - so the
|
|
1677
|
+
# probe carries the token and the route is only taken if that round trip
|
|
1678
|
+
# answered.
|
|
1679
|
+
opener_ready() {
|
|
1680
|
+
[ "${ACSTACK_OPEN_OPENER:-auto}" != "off" ] || return 1
|
|
1681
|
+
local tok
|
|
1682
|
+
tok=$(cat "$OPENER_TOKEN" 2>/dev/null) || return 1
|
|
1683
|
+
[ -n "$tok" ] || return 1
|
|
1684
|
+
# Seconds, not tens of them - someone just pressed a key and is watching.
|
|
1685
|
+
curl -fsS --connect-timeout 1 -m 2 -H "X-ACStack-Token: $tok" \
|
|
1686
|
+
"$OPENER/health" >/dev/null 2>&1
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
route() { # the route this machine will take, decided without taking it
|
|
1690
|
+
[ -n "${ACSTACK_OPEN_CMD:-}" ] && { echo "command"; return; }
|
|
1691
|
+
[ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && command -v xdg-open >/dev/null 2>&1 \
|
|
1692
|
+
&& { echo "xdg-open"; return; }
|
|
1693
|
+
local opener
|
|
1694
|
+
for opener in wslview wsl-open explorer.exe; do
|
|
1695
|
+
command -v "$opener" >/dev/null 2>&1 && { echo "$opener"; return; }
|
|
1696
|
+
done
|
|
1697
|
+
# Ahead of the bridge deliberately. The bridge drives a browser for an
|
|
1698
|
+
# agent: it attaches per tab, only after someone clicks the extension icon,
|
|
1699
|
+
# and it does so in a Chrome profile that is not the default browser. The
|
|
1700
|
+
# opener needs no click and reaches the browser the user actually uses. See
|
|
1701
|
+
# docs/adr/0006-document-opener-service.md.
|
|
1702
|
+
opener_ready && { echo "opener"; return; }
|
|
1703
|
+
bridge_attached && { echo "bridge"; return; }
|
|
1704
|
+
has_tty && { echo "link"; return; }
|
|
1705
|
+
echo "clipboard"
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
# A clickable link, on the terminal you are actually sitting at. OSC 8 is a
|
|
1709
|
+
# hyperlink the terminal draws itself, so ctrl+click reaches the browser on
|
|
1710
|
+
# your own machine with nothing installed anywhere in between. The bare URL
|
|
1711
|
+
# on the next line covers terminals that ignore OSC 8, and the clipboard
|
|
1712
|
+
# copy covers not wanting to click at all.
|
|
1713
|
+
link_screen() {
|
|
1714
|
+
printf '%s' "$URL" | acstack-clip
|
|
1715
|
+
{
|
|
1716
|
+
printf '\n Open on GitHub\n\n '
|
|
1717
|
+
printf '\033]8;;%s\033\\%s\033]8;;\033\\\n' "$URL" "$URL"
|
|
1718
|
+
printf ' ctrl+click the link, or paste it - it is on your clipboard\n\n'
|
|
1719
|
+
printf ' Press any key to go back.'
|
|
1720
|
+
} > /dev/tty
|
|
1721
|
+
read -rsn1 < /dev/tty
|
|
1722
|
+
printf '\n' > /dev/tty
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
ROUTE="$(route)"
|
|
1726
|
+
[ "$WHY" = 1 ] && { echo "$ROUTE"; exit 0; }
|
|
1727
|
+
|
|
1728
|
+
case "$ROUTE" in
|
|
1729
|
+
command)
|
|
1730
|
+
log "override: $ACSTACK_OPEN_CMD $URL"
|
|
1731
|
+
exec ${ACSTACK_OPEN_CMD} "$URL"
|
|
1732
|
+
;;
|
|
1733
|
+
xdg-open)
|
|
1734
|
+
# $BROWSER is cleared first: xdg-open reads it too, and $BROWSER may well
|
|
1735
|
+
# be pointing back here, which would loop.
|
|
1736
|
+
log "xdg-open: $URL"
|
|
1737
|
+
BROWSER= exec xdg-open "$URL"
|
|
1738
|
+
;;
|
|
1739
|
+
explorer.exe)
|
|
1740
|
+
# explorer.exe reports failure even when it opened the page. Ignore it.
|
|
1741
|
+
log "explorer.exe: $URL"
|
|
1742
|
+
explorer.exe "$URL" >/dev/null 2>&1
|
|
1743
|
+
exit 0
|
|
1744
|
+
;;
|
|
1745
|
+
wslview|wsl-open)
|
|
1746
|
+
log "$ROUTE: $URL"
|
|
1747
|
+
exec "$ROUTE" "$URL"
|
|
1748
|
+
;;
|
|
1749
|
+
opener)
|
|
1750
|
+
# One POST and the laptop opens it in whatever it considers the default
|
|
1751
|
+
# browser. No tab to arm, no extension, no CDP session to contend for.
|
|
1752
|
+
#
|
|
1753
|
+
# A URL carrying a double quote or a backslash would break out of the JSON
|
|
1754
|
+
# string it is about to become. Rather than escape it, fall through to the
|
|
1755
|
+
# link screen: those characters are not legal in a URL unescaped, so a URL
|
|
1756
|
+
# holding one is malformed and worth showing a human rather than sending.
|
|
1757
|
+
tok=$(cat "$OPENER_TOKEN" 2>/dev/null || true)
|
|
1758
|
+
case "$URL" in
|
|
1759
|
+
*'"'*|*'\\'*) tok="" ;;
|
|
1760
|
+
esac
|
|
1761
|
+
if [ -n "$tok" ] && curl -fsS --connect-timeout 2 -m 10 -X POST \
|
|
1762
|
+
-H "X-ACStack-Token: $tok" -H 'Content-Type: application/json' \
|
|
1763
|
+
--data "$(printf '{"url":"%s"}' "$URL")" \
|
|
1764
|
+
"$OPENER/open" 2>/dev/null | tr -d '[:space:]' | grep -q '"opened":true'; then
|
|
1765
|
+
log "opener $OPENER: $URL"
|
|
1766
|
+
exit 0
|
|
1767
|
+
fi
|
|
1768
|
+
log "opener $OPENER did not open it, handing over the link: $URL"
|
|
1769
|
+
if has_tty; then link_screen; exit 0; fi
|
|
1770
|
+
;;
|
|
1771
|
+
bridge)
|
|
1772
|
+
# Name the endpoint rather than inheriting one. Leaning on an ambient
|
|
1773
|
+
# $AGENT_BROWSER_CDP made this launch a headless Chrome on the VM when
|
|
1774
|
+
# run from a herdr popup, which reported success and opened nothing
|
|
1775
|
+
# anybody could see.
|
|
1776
|
+
#
|
|
1777
|
+
# The default session, deliberately: the relay takes one CDP client at a
|
|
1778
|
+
# time, so a session of our own cannot connect while an agent holds it.
|
|
1779
|
+
# That also means the new tab becomes the active one for any agent
|
|
1780
|
+
# driving that browser. ACSTACK_OPEN_BRIDGE=off trades the one-key open
|
|
1781
|
+
# for never touching it.
|
|
1782
|
+
if timeout 20 agent-browser --cdp "$BRIDGE" tab new >/dev/null 2>&1 \
|
|
1783
|
+
&& timeout 30 agent-browser --cdp "$BRIDGE" open "$URL" >/dev/null 2>&1; then
|
|
1784
|
+
log "bridge $BRIDGE: $URL"
|
|
1785
|
+
exit 0
|
|
1786
|
+
fi
|
|
1787
|
+
log "bridge $BRIDGE unavailable, handing over the link: $URL"
|
|
1788
|
+
if has_tty; then link_screen; exit 0; fi
|
|
1789
|
+
;;
|
|
1790
|
+
esac
|
|
1791
|
+
|
|
1792
|
+
[ "$ROUTE" = link ] && { log "link: $URL"; link_screen; exit 0; }
|
|
1793
|
+
|
|
1794
|
+
# No terminal to draw on and no browser to reach. The clipboard is still one
|
|
1795
|
+
# paste from the browser on the machine you are sitting at, which beats
|
|
1796
|
+
# nothing, but report failure anyway: a silent success here is what left
|
|
1797
|
+
# gh-dash saying "Opened in browser" about a page that never opened.
|
|
1798
|
+
if printf '%s' "$URL" | acstack-clip; then
|
|
1799
|
+
log "copied, not opened: $URL"
|
|
1800
|
+
note "URL copied, not opened" "No browser reachable from here. Paste: $URL"
|
|
1801
|
+
exit 3
|
|
1802
|
+
fi
|
|
1803
|
+
log "no route: $URL"
|
|
1804
|
+
note "Cannot open URL" "$URL"
|
|
1805
|
+
exit 1
|
|
1806
|
+
H
|
|
1807
|
+
chmod +x "$BIN/acstack-open"
|
|
1808
|
+
|
|
1809
|
+
cat > "$BIN/acstack-bridge" <<'H'
|
|
1810
|
+
#!/usr/bin/env bash
|
|
1811
|
+
# Look at the Playwriter bridge from the dev machine, and fix it where that is
|
|
1812
|
+
# possible from here.
|
|
1813
|
+
#
|
|
1814
|
+
# acstack-bridge status what is up, on both machines
|
|
1815
|
+
# acstack-bridge fix [what] restart relay | tunnel | both (default both)
|
|
1816
|
+
#
|
|
1817
|
+
# Restarting either is a Windows scheduled task, so this machine cannot do it
|
|
1818
|
+
# directly. It asks the opener, which runs over there and can. When the opener
|
|
1819
|
+
# is not reachable either, it prints the handoff block to paste into a
|
|
1820
|
+
# PowerShell on the laptop instead - the same block the bridge skill defines.
|
|
1821
|
+
#
|
|
1822
|
+
# What it still cannot do is arm the extension. That is a click inside a
|
|
1823
|
+
# third-party extension and nothing on either machine can press it.
|
|
1824
|
+
# Installed by acstack.
|
|
1825
|
+
set -uo pipefail
|
|
1826
|
+
|
|
1827
|
+
BRIDGE="${ACSTACK_BRIDGE_URL:-http://127.0.0.1:19988}"
|
|
1828
|
+
OPENER="${ACSTACK_OPENER_URL:-http://127.0.0.1:19989}"
|
|
1829
|
+
OPENER_TOKEN="${XDG_CONFIG_HOME:-$HOME/.config}/acstack/opener.token"
|
|
1830
|
+
FREE_PORT="${ACSTACK_FREE_PORT:-$HOME/.claude/acstack/bin/vm-free-port.sh}"
|
|
1831
|
+
|
|
1832
|
+
CMD="${1:-status}"
|
|
1833
|
+
WHAT="${2:-both}"
|
|
1834
|
+
|
|
1835
|
+
tok() { cat "$OPENER_TOKEN" 2>/dev/null; }
|
|
1836
|
+
|
|
1837
|
+
clear_port() { # clear_port <port> - let go of a forward this machine still holds
|
|
1838
|
+
# A tunnel that will not rebind is usually held from this side: the VM's sshd
|
|
1839
|
+
# still owns the forward channel of a session that dropped. Restarting the
|
|
1840
|
+
# task on the laptop then fails exactly the way it failed before, because
|
|
1841
|
+
# nothing here let go of the port. So this runs first.
|
|
1842
|
+
#
|
|
1843
|
+
# The clearer ships with acstack and may simply not be installed on a machine
|
|
1844
|
+
# that got the terminal workspace some other way. Worth saying, but not a
|
|
1845
|
+
# reason to refuse the restart, which may be all that was needed.
|
|
1846
|
+
if [ ! -f "$FREE_PORT" ]; then
|
|
1847
|
+
echo "no port clearer at $FREE_PORT - asking for the restart without freeing $1 first." >&2
|
|
1848
|
+
return 0
|
|
1849
|
+
fi
|
|
1850
|
+
#
|
|
1851
|
+
# A clearer that predates --port is the awkward case in between: it exists,
|
|
1852
|
+
# so the missing-file warning never fires, but it only knows how to free
|
|
1853
|
+
# 19988. Called bare it would clear the bridge port twice, never touch 19989,
|
|
1854
|
+
# and hand back two successes - so the caller would believe both ports were
|
|
1855
|
+
# freed. Report the version and clear nothing; guessing is worse than saying.
|
|
1856
|
+
# It announces its age two ways: the flag is nowhere in its text, or it takes
|
|
1857
|
+
# the flag, does not recognise the argument, and exits 64 - the usage code,
|
|
1858
|
+
# which is not "the port is stuck" and is not worth a retry without the flag.
|
|
1859
|
+
local older="is older than this branch, so it cannot free $1 on request."
|
|
1860
|
+
local fix="Update acstack on this machine to refresh it. Asking for the restart anyway."
|
|
1861
|
+
if ! grep -q -- '--port' "$FREE_PORT" 2>/dev/null; then
|
|
1862
|
+
echo "$FREE_PORT $older It has no --port support at all. $fix" >&2
|
|
1863
|
+
return 0
|
|
1864
|
+
fi
|
|
1865
|
+
local rc=0
|
|
1866
|
+
bash "$FREE_PORT" --port "$1" >/dev/null 2>&1 || rc=$?
|
|
1867
|
+
if [ "$rc" = "64" ]; then
|
|
1868
|
+
echo "$FREE_PORT $older It refused --port with the usage code 64. $fix" >&2
|
|
1869
|
+
elif [ "$rc" != "0" ]; then
|
|
1870
|
+
echo "$FREE_PORT could not free $1 - asking for the restart anyway." >&2
|
|
1871
|
+
fi
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
opener_call() { # opener_call <method> <path> [data]
|
|
1875
|
+
local t; t=$(tok) || return 1
|
|
1876
|
+
[ -n "$t" ] || return 1
|
|
1877
|
+
if [ "$1" = "POST" ]; then
|
|
1878
|
+
curl -fsS --connect-timeout 2 -m 40 -X POST -H "X-ACStack-Token: $t" \
|
|
1879
|
+
-H 'Content-Type: application/json' --data "${3:-{\}}" "$OPENER$2" 2>/dev/null
|
|
1880
|
+
else
|
|
1881
|
+
curl -fsS --connect-timeout 2 -m 10 -H "X-ACStack-Token: $t" "$OPENER$2" 2>/dev/null
|
|
1882
|
+
fi
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
handoff() { # what this machine cannot do, in the shape the other side expects
|
|
1886
|
+
# Unquoted, because $1 names what is being asked for - so every PowerShell $
|
|
1887
|
+
# inside has to be escaped or the shell eats it. A handoff block is pasted
|
|
1888
|
+
# verbatim into a shell on the other machine, so a mangled one is worse than
|
|
1889
|
+
# no block at all.
|
|
1890
|
+
cat >&2 <<HANDOFF
|
|
1891
|
+
|
|
1892
|
+
--- BEGIN VM-SIDE HANDOFF (to Windows agent) ---
|
|
1893
|
+
Context: the bridge needs $1 on the laptop, and the opener on 19989 is not
|
|
1894
|
+
reachable from this machine either, so nothing here can do it.
|
|
1895
|
+
|
|
1896
|
+
Run on the Windows laptop:
|
|
1897
|
+
Stop-ScheduledTask -TaskName 'Playwriter-*'
|
|
1898
|
+
Start-ScheduledTask -TaskName 'Playwriter-Relay'
|
|
1899
|
+
Start-ScheduledTask -TaskName 'Playwriter-Opener'
|
|
1900
|
+
Get-ScheduledTask -TaskName 'Playwriter-Tunnel-*','Playwriter-OpenerTunnel-*' |
|
|
1901
|
+
ForEach-Object { Start-ScheduledTask -TaskName \$_.TaskName }
|
|
1902
|
+
.\doctor.ps1
|
|
1903
|
+
|
|
1904
|
+
Expected result:
|
|
1905
|
+
doctor.ps1 exits with all green rows, including "Relay listening on 19988"
|
|
1906
|
+
and "Opener listening on 19989", and no tunnel-*.stuck markers.
|
|
1907
|
+
|
|
1908
|
+
Report back:
|
|
1909
|
+
The doctor.ps1 output (paste the table).
|
|
1910
|
+
--- END VM-SIDE HANDOFF ---
|
|
1911
|
+
HANDOFF
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
case "$CMD" in
|
|
1915
|
+
status)
|
|
1916
|
+
# This machine's own view first: these say whether the tunnels arrived,
|
|
1917
|
+
# which is the half the laptop cannot see.
|
|
1918
|
+
if curl -fsS --connect-timeout 1 -m 3 "$BRIDGE/json/version" >/dev/null 2>&1; then
|
|
1919
|
+
armed=$(curl -fsS --connect-timeout 1 -m 3 "$BRIDGE/json/list" 2>/dev/null | tr -d '[:space:]')
|
|
1920
|
+
case "$armed" in
|
|
1921
|
+
''|'[]') echo "relay: reachable, but the extension is armed on no tab" ;;
|
|
1922
|
+
*) echo "relay: reachable, extension armed" ;;
|
|
1923
|
+
esac
|
|
1924
|
+
else
|
|
1925
|
+
echo "relay: not reachable on $BRIDGE"
|
|
1926
|
+
fi
|
|
1927
|
+
|
|
1928
|
+
if health=$(opener_call GET /health); then
|
|
1929
|
+
echo "opener: reachable - $(printf '%s' "$health" | tr -d '[:space:]')"
|
|
1930
|
+
elif [ -z "$(tok)" ]; then
|
|
1931
|
+
echo "opener: no token at $OPENER_TOKEN (run register-tasks.ps1 on the laptop)"
|
|
1932
|
+
else
|
|
1933
|
+
echo "opener: not reachable on $OPENER"
|
|
1934
|
+
fi
|
|
1935
|
+
|
|
1936
|
+
# And the laptop's view, which only the opener can fetch.
|
|
1937
|
+
if tasks=$(opener_call GET /bridge/health); then
|
|
1938
|
+
echo "tasks: $(printf '%s' "$tasks" | tr -d '[:space:]')"
|
|
1939
|
+
else
|
|
1940
|
+
echo "tasks: unknown - the opener is the only way to see them from here"
|
|
1941
|
+
fi
|
|
1942
|
+
echo "route: $(acstack-open --why 2>/dev/null || echo unknown)"
|
|
1943
|
+
;;
|
|
1944
|
+
fix)
|
|
1945
|
+
case "$WHAT" in
|
|
1946
|
+
relay|tunnel|both) ;;
|
|
1947
|
+
*) echo "usage: acstack-bridge fix [relay|tunnel|both]" >&2; exit 2 ;;
|
|
1948
|
+
esac
|
|
1949
|
+
# A tunnel restart is the only case where a port here is in the way. Both
|
|
1950
|
+
# ports get cleared, because the opener's 19989 is forwarded by its own task
|
|
1951
|
+
# and wedges on its own. fix relay touches no tunnel, so clearing a port
|
|
1952
|
+
# there would be tearing down a working connection to fix something else.
|
|
1953
|
+
if [ "$WHAT" != "relay" ]; then
|
|
1954
|
+
clear_port 19988
|
|
1955
|
+
clear_port 19989
|
|
1956
|
+
fi
|
|
1957
|
+
if out=$(opener_call POST /bridge/restart "$(printf '{"what":"%s"}' "$WHAT")"); then
|
|
1958
|
+
echo "asked the laptop to restart: $WHAT"
|
|
1959
|
+
printf '%s\n' "$out"
|
|
1960
|
+
# Restarting is not recovering. Give the tunnels a moment to rebind, then
|
|
1961
|
+
# say whether they actually came back rather than reporting the request.
|
|
1962
|
+
sleep 5
|
|
1963
|
+
rc=0
|
|
1964
|
+
if curl -fsS --connect-timeout 1 -m 3 "$BRIDGE/json/version" >/dev/null 2>&1; then
|
|
1965
|
+
echo "relay is answering again on $BRIDGE"
|
|
1966
|
+
else
|
|
1967
|
+
echo "the restart was accepted but the relay is still not answering here on 19988." >&2
|
|
1968
|
+
echo " if this persists the port may be wedged on this machine:" >&2
|
|
1969
|
+
echo " bash $FREE_PORT --port 19988" >&2
|
|
1970
|
+
rc=1
|
|
1971
|
+
fi
|
|
1972
|
+
# Two tunnels, two verdicts. A relay that came back says nothing about
|
|
1973
|
+
# the opener, and an opener that stayed down is the half that leaves this
|
|
1974
|
+
# machine unable to reach a browser at all - so it is checked, and named.
|
|
1975
|
+
if [ "$WHAT" != "relay" ]; then
|
|
1976
|
+
if opener_call GET /health >/dev/null; then
|
|
1977
|
+
echo "opener is answering again on $OPENER"
|
|
1978
|
+
else
|
|
1979
|
+
echo "the restart was accepted but the opener is still not answering here on 19989." >&2
|
|
1980
|
+
echo " if this persists the port may be wedged on this machine:" >&2
|
|
1981
|
+
echo " bash $FREE_PORT --port 19989" >&2
|
|
1982
|
+
rc=1
|
|
1983
|
+
fi
|
|
1984
|
+
fi
|
|
1985
|
+
exit $rc
|
|
1986
|
+
fi
|
|
1987
|
+
handoff "a restart of $WHAT"
|
|
1988
|
+
exit 1
|
|
1989
|
+
;;
|
|
1990
|
+
*)
|
|
1991
|
+
echo "usage: acstack-bridge [status|fix [relay|tunnel|both]]" >&2
|
|
1992
|
+
exit 2
|
|
1993
|
+
;;
|
|
1994
|
+
esac
|
|
1995
|
+
H
|
|
1996
|
+
chmod +x "$BIN/acstack-bridge"
|
|
1997
|
+
|
|
1998
|
+
say "helpers written: acstack-diff, acstack-dash, acstack-db, acstack-md, acstack-view"
|
|
1999
|
+
say "remote helpers written: acstack-clip, acstack-open, acstack-bridge"
|
|
2000
|
+
say "herdr helpers written: acstack-popup, acstack-herdr-tab, acstack-herdr-renumber, acstack-edit"
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
apply_herdr() {
|
|
2004
|
+
have herdr || { say "herdr not installed, skipping keymap"; return 0; }
|
|
2005
|
+
# Every popup runs through acstack-popup. disable reaches here without
|
|
2006
|
+
# apply, so after an upgrade the helper may not exist yet.
|
|
2007
|
+
[ -x "$BIN/acstack-popup" ] || write_helpers
|
|
2008
|
+
mkdir -p "$(dirname "$HERDR_CFG")"; touch "$HERDR_CFG"
|
|
2009
|
+
local mod wt diffkey dashkey filekey filetabkey difftabkey dashtabkey
|
|
2010
|
+
local pickkey picktabkey gitkey gittabkey dbkey dbtabkey scrollbars
|
|
2011
|
+
local theme themeauto themelight toast labels panelsort
|
|
2012
|
+
mod=$(cfg_get herdr.chord_modifier alt)
|
|
2013
|
+
wt=$(cfg_get herdr.worktrees_directory "~/.herdr/worktrees")
|
|
2014
|
+
# f, not d: Windows Terminal keeps alt+shift+d for "duplicate pane", so the
|
|
2015
|
+
# tab half of a d pair never reaches herdr. Both diff keys sit on f instead.
|
|
2016
|
+
diffkey=$(cfg_get diffnav.popup_key "alt+f")
|
|
2017
|
+
dashkey=$(cfg_get gh_dash.popup_key "alt+i")
|
|
2018
|
+
filekey=$(cfg_get yazi.popup_key "alt+y")
|
|
2019
|
+
filetabkey=$(cfg_get yazi.tab_key "alt+shift+y")
|
|
2020
|
+
difftabkey=$(cfg_get diffnav.tab_key "alt+shift+f")
|
|
2021
|
+
dashtabkey=$(cfg_get gh_dash.tab_key "alt+shift+i")
|
|
2022
|
+
pickkey=$(cfg_get diffnav.pick_key "alt+x")
|
|
2023
|
+
picktabkey=$(cfg_get diffnav.pick_tab_key "alt+shift+x")
|
|
2024
|
+
gitkey=$(cfg_get lazygit.popup_key "alt+g")
|
|
2025
|
+
gittabkey=$(cfg_get lazygit.tab_key "alt+shift+g")
|
|
2026
|
+
dbkey=$(cfg_get harlequin.popup_key "alt+q")
|
|
2027
|
+
dbtabkey=$(cfg_get harlequin.tab_key "alt+shift+q")
|
|
2028
|
+
# herdr counts the scrollbar column inside the pane rect but reports the
|
|
2029
|
+
# full rect width to the program running in the pane. A full-screen app then
|
|
2030
|
+
# writes one column more than it has, so every wrapped row starts a column
|
|
2031
|
+
# left of the row above it. Turning the scrollbar off gives the column back.
|
|
2032
|
+
scrollbars=$(cfg_get herdr.pane_scrollbars false)
|
|
2033
|
+
# Four settings that are not keys. Each one is empty by default and then
|
|
2034
|
+
# written only when the config names it, so a machine that says nothing
|
|
2035
|
+
# keeps whatever herdr already paints. The exception is the pane label,
|
|
2036
|
+
# which is written either way: several agents in several panes is what this
|
|
2037
|
+
# workspace is for, and an unlabelled pane does not say which agent it holds.
|
|
2038
|
+
theme=$(cfg_get herdr.theme.name "")
|
|
2039
|
+
themeauto=$(cfg_get herdr.theme.auto_switch true)
|
|
2040
|
+
themelight=$(cfg_get herdr.theme.light "")
|
|
2041
|
+
toast=$(cfg_get herdr.toast_delivery "")
|
|
2042
|
+
labels=$(cfg_get herdr.agent_labels_on_pane_borders true)
|
|
2043
|
+
panelsort=$(cfg_get herdr.agent_panel_sort "")
|
|
2044
|
+
[ "$(cfg_get diffnav.enabled true)" = "true" ] \
|
|
2045
|
+
|| { diffkey=""; difftabkey=""; pickkey=""; picktabkey=""; }
|
|
2046
|
+
[ "$(cfg_get gh_dash.enabled true)" = "true" ] || { dashkey=""; dashtabkey=""; }
|
|
2047
|
+
[ "$(cfg_get yazi.enabled true)" = "true" ] || { filekey=""; filetabkey=""; }
|
|
2048
|
+
[ "$(cfg_get lazygit.enabled true)" = "true" ] || { gitkey=""; gittabkey=""; }
|
|
2049
|
+
[ "$(cfg_get harlequin.enabled true)" = "true" ] || { dbkey=""; dbtabkey=""; }
|
|
2050
|
+
|
|
2051
|
+
ACSTACK_ARGS="$mod|$wt|$diffkey|$dashkey|$filekey|$filetabkey|$difftabkey|$dashtabkey|$pickkey|$picktabkey|$gitkey|$gittabkey|$dbkey|$dbtabkey|$scrollbars|$theme|$themeauto|$themelight|$toast|$labels|$panelsort|$BEGIN|$END" ACSTACK_BIN="$BIN" py "$HERDR_CFG" <<'PY'
|
|
2052
|
+
import os, re, sys
|
|
2053
|
+
path = sys.argv[1]
|
|
2054
|
+
# herdr runs a popup with its server's PATH, which can lack $BIN. Each popup
|
|
2055
|
+
# goes through acstack-popup by absolute path, which puts $BIN back on PATH.
|
|
2056
|
+
popup = os.path.join(os.environ["ACSTACK_BIN"], "acstack-popup")
|
|
2057
|
+
(mod, wt, diffkey, dashkey, filekey, filetabkey, difftabkey, dashtabkey,
|
|
2058
|
+
pickkey, picktabkey, gitkey, gittabkey, dbkey, dbtabkey, scrollbars,
|
|
2059
|
+
theme, themeauto, themelight, toast, labels, panelsort,
|
|
2060
|
+
begin, end) = os.environ["ACSTACK_ARGS"].split("|")
|
|
2061
|
+
|
|
2062
|
+
# key, command, description, herdr custom-command type, popup size.
|
|
2063
|
+
# type "shell" takes no size: herdr rejects width/height on a non-popup.
|
|
2064
|
+
CUSTOM = [
|
|
2065
|
+
(diffkey, "acstack-diff", "diff: branch vs dev", "popup", "90%"),
|
|
2066
|
+
(difftabkey, "acstack-herdr-tab diff acstack-diff", "diff: branch vs dev (tab)", "shell", None),
|
|
2067
|
+
(pickkey, "acstack-diff pick", "diff: pick what to compare", "popup", "90%"),
|
|
2068
|
+
(picktabkey, "acstack-herdr-tab diffpick acstack-diff pick",
|
|
2069
|
+
"diff: pick what to compare (tab)", "shell", None),
|
|
2070
|
+
(dashkey, "acstack-dash", "PR dashboard", "popup", "95%"),
|
|
2071
|
+
(dashtabkey, "acstack-herdr-tab dash acstack-dash", "PR dashboard (tab)", "shell", None),
|
|
2072
|
+
(filekey, "yazi", "file tree", "popup", "95%"),
|
|
2073
|
+
(filetabkey, "acstack-herdr-tab yazi yazi", "file tree (tab)", "shell", None),
|
|
2074
|
+
(gitkey, "lazygit", "git: stage, commit, push", "popup", "90%"),
|
|
2075
|
+
(gittabkey, "acstack-herdr-tab lazygit lazygit",
|
|
2076
|
+
"git: stage, commit, push (tab)", "shell", None),
|
|
2077
|
+
(dbkey, "acstack-db", "db: harlequin SQL editor", "popup", "90%"),
|
|
2078
|
+
(dbtabkey, "acstack-herdr-tab harlequin acstack-db",
|
|
2079
|
+
"db: harlequin SQL editor (tab)", "shell", None),
|
|
2080
|
+
]
|
|
2081
|
+
|
|
2082
|
+
def custom_blocks():
|
|
2083
|
+
out = []
|
|
2084
|
+
for key, cmd, desc, typ, size in CUSTOM:
|
|
2085
|
+
if not key:
|
|
2086
|
+
continue
|
|
2087
|
+
if typ == "popup":
|
|
2088
|
+
cmd = f"{popup} {cmd}"
|
|
2089
|
+
out += ["", "[[keys.command]]", f'key = "{key}"', f'type = "{typ}"',
|
|
2090
|
+
f'command = "{cmd}"', f'description = "{desc}"']
|
|
2091
|
+
if size:
|
|
2092
|
+
out += [f'width = "{size}"', f'height = "{size}"']
|
|
2093
|
+
return out
|
|
2094
|
+
text = open(path).read() if os.path.isfile(path) else ""
|
|
2095
|
+
# Drop any previous managed region so this is idempotent.
|
|
2096
|
+
text = re.sub(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", "", text, flags=re.S)
|
|
2097
|
+
|
|
2098
|
+
keys = []
|
|
2099
|
+
if mod != "none":
|
|
2100
|
+
m = mod
|
|
2101
|
+
keys = [
|
|
2102
|
+
f'focus_agent = "{m}+1..9"',
|
|
2103
|
+
f'focus_pane_left = ["prefix+h", "{m}+h"]',
|
|
2104
|
+
f'focus_pane_down = ["prefix+j", "{m}+j"]',
|
|
2105
|
+
f'focus_pane_up = ["prefix+k", "{m}+k"]',
|
|
2106
|
+
f'focus_pane_right = ["prefix+l", "{m}+l"]',
|
|
2107
|
+
f'next_agent = "{m}+n"',
|
|
2108
|
+
f'previous_agent = "{m}+u"',
|
|
2109
|
+
f'next_tab = ["prefix+n", "{m}+right"]',
|
|
2110
|
+
f'previous_tab = ["prefix+p", "{m}+left"]',
|
|
2111
|
+
f'next_workspace = ["{m}+down"]',
|
|
2112
|
+
f'previous_workspace = ["{m}+up"]',
|
|
2113
|
+
# herdr puts switch_tab on prefix+1..9 by default. Move it aside so
|
|
2114
|
+
# switch_workspace below can have the plain digits.
|
|
2115
|
+
f'switch_tab = "prefix+{m}+1..9"',
|
|
2116
|
+
f'toggle_sidebar = ["prefix+b", "{m}+s"]',
|
|
2117
|
+
# g belongs to git only when lazygit is on. alt+g is lazygit and
|
|
2118
|
+
# alt+shift+g is its tab, so goto moves off the letter on both layers:
|
|
2119
|
+
# prefix+g one key away from alt+g reads as the same thing. With
|
|
2120
|
+
# lazygit off, nothing wants the letter and goto keeps it.
|
|
2121
|
+
('goto = "prefix+t"' if gitkey else f'goto = ["prefix+g", "{m}+g"]'),
|
|
2122
|
+
f'zoom = ["prefix+z", "{m}+z"]',
|
|
2123
|
+
f'last_pane = "{m}+a"',
|
|
2124
|
+
f'new_tab = ["prefix+c", "{m}+c"]',
|
|
2125
|
+
f'new_workspace = ["prefix+shift+n", "{m}+w"]',
|
|
2126
|
+
# Also off g, for the same reason, and back on it when lazygit is off.
|
|
2127
|
+
(f'new_worktree = "{m}+r"' if gitkey
|
|
2128
|
+
else f'new_worktree = ["prefix+shift+g", "{m}+r"]'),
|
|
2129
|
+
f'split_vertical = ["prefix+v", "{m}+e"]',
|
|
2130
|
+
f'split_horizontal = ["prefix+minus", "{m}+minus"]',
|
|
2131
|
+
]
|
|
2132
|
+
|
|
2133
|
+
# herdr leaves switch_workspace unbound, so without this there is no way to
|
|
2134
|
+
# reach a workspace by number at all. It is prefix-only, so it is written even
|
|
2135
|
+
# when the chord family is off: opting out of alt chords should not cost you
|
|
2136
|
+
# workspace numbers.
|
|
2137
|
+
keys.append('switch_workspace = "prefix+1..9"')
|
|
2138
|
+
|
|
2139
|
+
# See the comment on `scrollbars` above for what the scrollbar column costs.
|
|
2140
|
+
# The label is written either way; the sort order only when the config names
|
|
2141
|
+
# one, because herdr's own default is a reasonable answer and this script has
|
|
2142
|
+
# no better one.
|
|
2143
|
+
ui = [
|
|
2144
|
+
"pane_scrollbars = " + ("true" if scrollbars == "true" else "false"),
|
|
2145
|
+
"show_agent_labels_on_pane_borders = " + ("true" if labels == "true" else "false"),
|
|
2146
|
+
]
|
|
2147
|
+
if panelsort:
|
|
2148
|
+
ui.append(f'agent_panel_sort = "{panelsort}"')
|
|
2149
|
+
|
|
2150
|
+
# A theme, and a toast. Both tables stay empty unless the config names a value,
|
|
2151
|
+
# so a machine that says nothing about either keeps what herdr already does.
|
|
2152
|
+
#
|
|
2153
|
+
# auto_switch asks the attached client for its background colour over OSC 11
|
|
2154
|
+
# and picks dark_name or light_name from the answer. That is the setting that
|
|
2155
|
+
# lets one herdr server serve a dark terminal on one device and a light one on
|
|
2156
|
+
# another - an e-ink panel among them - without either client changing the
|
|
2157
|
+
# other. dark_name repeats `name` because a client that answers nothing at all
|
|
2158
|
+
# falls back to dark.
|
|
2159
|
+
themelines = []
|
|
2160
|
+
if theme:
|
|
2161
|
+
themelines = [f'name = "{theme}"']
|
|
2162
|
+
if themelight:
|
|
2163
|
+
themelines += [
|
|
2164
|
+
"auto_switch = " + ("true" if themeauto == "true" else "false"),
|
|
2165
|
+
f'dark_name = "{theme}"',
|
|
2166
|
+
f'light_name = "{themelight}"',
|
|
2167
|
+
]
|
|
2168
|
+
|
|
2169
|
+
toastlines = [f'delivery = "{toast}"'] if toast else []
|
|
2170
|
+
|
|
2171
|
+
# TOML forbids a duplicate key, so a hand-written keymap that already sets
|
|
2172
|
+
# something this script manages would make the merged file unparseable and
|
|
2173
|
+
# herdr would reject the lot. Drop our own keys from the user's [keys] table,
|
|
2174
|
+
# and any [[keys.command]] bound to a key we are about to claim, before
|
|
2175
|
+
# inserting. Anything we do not manage is left exactly as it was.
|
|
2176
|
+
def names(lines):
|
|
2177
|
+
return {k.split("=", 1)[0].strip() for k in lines if "=" in k}
|
|
2178
|
+
|
|
2179
|
+
|
|
2180
|
+
managed = names(keys)
|
|
2181
|
+
# One set per table. An empty set strips nothing, so a table this run writes
|
|
2182
|
+
# no value into leaves whatever the user wrote in it exactly as it was.
|
|
2183
|
+
MANAGED_BY_TABLE = {"[keys]": managed, "[ui]": names(ui),
|
|
2184
|
+
"[theme]": names(themelines), "[ui.toast]": names(toastlines)}
|
|
2185
|
+
# A theme is one setting spelled across four keys, so it is claimed whole or
|
|
2186
|
+
# not at all. Claiming only what gets written leaves a hand-written
|
|
2187
|
+
# `auto_switch` and `light_name` beside the new `name`, and a light client then
|
|
2188
|
+
# keeps the theme the user is replacing.
|
|
2189
|
+
if themelines:
|
|
2190
|
+
MANAGED_BY_TABLE["[theme]"] = {"name", "auto_switch", "dark_name", "light_name"}
|
|
2191
|
+
claimed = {key for key, *_ in CUSTOM if key}
|
|
2192
|
+
|
|
2193
|
+
|
|
2194
|
+
def strip_managed(table, names):
|
|
2195
|
+
# Drop the settings this script manages, and the comment block that
|
|
2196
|
+
# introduced each one. Removing the setting alone strands its comment: the
|
|
2197
|
+
# user is left with a run of bare headings describing settings that are no
|
|
2198
|
+
# longer there, which reads as a broken config.
|
|
2199
|
+
#
|
|
2200
|
+
# A value can run over several lines, because every key here holds an array
|
|
2201
|
+
# and TOML lets one span as many lines as it likes. Dropping the first line
|
|
2202
|
+
# alone strands the rest as bare list items, which is invalid TOML - and
|
|
2203
|
+
# herdr answers that by rejecting the whole file, so one hand-written
|
|
2204
|
+
# binding written that way would cost the user every binding.
|
|
2205
|
+
out, pending, depth = [], [], 0
|
|
2206
|
+
for ln in table:
|
|
2207
|
+
if depth > 0:
|
|
2208
|
+
depth += ln.count("[") - ln.count("]")
|
|
2209
|
+
continue
|
|
2210
|
+
stripped = ln.strip()
|
|
2211
|
+
if stripped.startswith("#") or not stripped:
|
|
2212
|
+
pending.append(ln)
|
|
2213
|
+
continue
|
|
2214
|
+
if "=" in ln and ln.split("=", 1)[0].strip() in names:
|
|
2215
|
+
pending = []
|
|
2216
|
+
value = ln.split("=", 1)[1]
|
|
2217
|
+
depth = value.count("[") - value.count("]")
|
|
2218
|
+
continue
|
|
2219
|
+
out.extend(pending)
|
|
2220
|
+
pending = []
|
|
2221
|
+
out.append(ln)
|
|
2222
|
+
out.extend(pending)
|
|
2223
|
+
return out
|
|
2224
|
+
|
|
2225
|
+
|
|
2226
|
+
# A table header on its own line, and nothing else. `line.startswith("[")` is
|
|
2227
|
+
# not enough: a value spanning several lines puts its own list items at the
|
|
2228
|
+
# start of a line, and ` [1, 2],` would then read as a new table.
|
|
2229
|
+
HEADER = re.compile(r"^\s*\[\[?[^\[\]]*\]\]?\s*(?:#.*)?$")
|
|
2230
|
+
|
|
2231
|
+
|
|
2232
|
+
def tables(lines):
|
|
2233
|
+
current, depth = [], 0
|
|
2234
|
+
for line in lines:
|
|
2235
|
+
if depth <= 0 and HEADER.match(line) and current:
|
|
2236
|
+
yield current
|
|
2237
|
+
current = []
|
|
2238
|
+
# Only outside a multi-line value, for the same reason.
|
|
2239
|
+
if not HEADER.match(line):
|
|
2240
|
+
depth += line.count("[") - line.count("]")
|
|
2241
|
+
depth = max(depth, 0)
|
|
2242
|
+
current.append(line)
|
|
2243
|
+
if current:
|
|
2244
|
+
yield current
|
|
2245
|
+
|
|
2246
|
+
|
|
2247
|
+
kept = []
|
|
2248
|
+
for table in tables(text.splitlines()):
|
|
2249
|
+
header = table[0].strip()
|
|
2250
|
+
# The managed block re-declares [worktrees] in full, and TOML forbids
|
|
2251
|
+
# declaring a table twice.
|
|
2252
|
+
if header == "[worktrees]":
|
|
2253
|
+
continue
|
|
2254
|
+
if header == "[[keys.command]]":
|
|
2255
|
+
bound = re.search(r'(?m)^\s*key\s*=\s*"([^"]+)"', "\n".join(table))
|
|
2256
|
+
if bound and bound.group(1) in claimed:
|
|
2257
|
+
continue
|
|
2258
|
+
if header in MANAGED_BY_TABLE:
|
|
2259
|
+
table = strip_managed(table, MANAGED_BY_TABLE[header])
|
|
2260
|
+
kept.extend(table)
|
|
2261
|
+
text = "\n".join(kept)
|
|
2262
|
+
if text and not text.endswith("\n"):
|
|
2263
|
+
text += "\n"
|
|
2264
|
+
|
|
2265
|
+
def merge(text, header, lines):
|
|
2266
|
+
# Merge into a table the user already declared rather than declaring a
|
|
2267
|
+
# second one, because TOML forbids declaring the same table twice. Returns
|
|
2268
|
+
# the text and whether the merge happened.
|
|
2269
|
+
# re.escape, because a sub-table header carries a dot: an unescaped
|
|
2270
|
+
# "ui.toast" matches "[ui-toast]" too.
|
|
2271
|
+
head = r"^\[" + re.escape(header) + r"\][ \t]*\n"
|
|
2272
|
+
if not re.search(head, text, flags=re.M):
|
|
2273
|
+
return text, False
|
|
2274
|
+
region = begin + "\n" + "\n".join(lines) + "\n" + end
|
|
2275
|
+
return re.sub(head, f"[{header}]\n" + region + "\n",
|
|
2276
|
+
text, count=1, flags=re.M), True
|
|
2277
|
+
|
|
2278
|
+
|
|
2279
|
+
block = []
|
|
2280
|
+
for header, lines in (("keys", keys), ("ui", ui),
|
|
2281
|
+
("theme", themelines), ("ui.toast", toastlines)):
|
|
2282
|
+
if not lines:
|
|
2283
|
+
continue
|
|
2284
|
+
text, merged = merge(text, header, lines)
|
|
2285
|
+
if merged:
|
|
2286
|
+
continue
|
|
2287
|
+
if block:
|
|
2288
|
+
block.append("")
|
|
2289
|
+
block += [f"[{header}]"] + lines
|
|
2290
|
+
|
|
2291
|
+
# [[keys.command]] and [worktrees] are top-level, so they can never sit inside
|
|
2292
|
+
# a merged region and always land in a block of their own at the end.
|
|
2293
|
+
block += custom_blocks() + ["", "[worktrees]", f'directory = "{wt}"']
|
|
2294
|
+
text = text.rstrip("\n") + "\n\n" + "\n".join([begin] + block + [end]) + "\n"
|
|
2295
|
+
|
|
2296
|
+
open(path, "w").write(text)
|
|
2297
|
+
PY
|
|
2298
|
+
if herdr config check 2>&1 | grep -q "^config: ok"; then
|
|
2299
|
+
herdr server reload-config >/dev/null 2>&1
|
|
2300
|
+
say "herdr keymap applied and reloaded"
|
|
2301
|
+
else
|
|
2302
|
+
say "herdr config check failed, review $HERDR_CFG"
|
|
2303
|
+
herdr config check 2>&1 | sed 's/^/ /' | head -5
|
|
2304
|
+
fi
|
|
2305
|
+
|
|
2306
|
+
local integ; integ=$(cfg_get herdr.integration none)
|
|
2307
|
+
if [ "$integ" != "none" ] && [ -n "$integ" ]; then
|
|
2308
|
+
herdr integration install "$integ" >/dev/null 2>&1 && say "herdr $integ state hook installed"
|
|
2309
|
+
fi
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
build_tuicr_fork() {
|
|
2313
|
+
# TEMPORARY path. Builds the two unmerged upstream pull requests the config
|
|
2314
|
+
# comments name. Once both land, set build_from_fork: false and this whole
|
|
2315
|
+
# function stops being reachable.
|
|
2316
|
+
# `have cargo` is not enough: a rustup shim on PATH with no default
|
|
2317
|
+
# toolchain installed exits non-zero on every invocation.
|
|
2318
|
+
if ! cargo --version >/dev/null 2>&1; then
|
|
2319
|
+
say "tuicr fork build needs a working cargo (see rustup.rs), skipping"
|
|
2320
|
+
cargo --version 2>&1 | head -2 | sed 's/^/ /'
|
|
2321
|
+
return 1
|
|
2322
|
+
fi
|
|
2323
|
+
have git || { say "tuicr fork build needs git, skipping"; return 1; }
|
|
2324
|
+
local url branch dir
|
|
2325
|
+
url=$(cfg_get tuicr.fork "https://github.com/ac8318740/tuicr")
|
|
2326
|
+
branch=$(cfg_get tuicr.fork_branch "local/daily")
|
|
2327
|
+
dir="${ACSTACK_TUICR_SRC:-$HOME/tuicr}"
|
|
2328
|
+
if [ -d "$dir/.git" ]; then
|
|
2329
|
+
git -C "$dir" fetch --quiet --all 2>/dev/null
|
|
2330
|
+
git -C "$dir" checkout --quiet "$branch" 2>/dev/null || { say "tuicr: no branch $branch in $dir"; return 1; }
|
|
2331
|
+
git -C "$dir" pull --quiet --ff-only 2>/dev/null
|
|
2332
|
+
else
|
|
2333
|
+
git clone --quiet -b "$branch" "$url" "$dir" || { say "tuicr: clone failed"; return 1; }
|
|
2334
|
+
fi
|
|
2335
|
+
say "building tuicr from $branch (a few minutes on a cold cache)"
|
|
2336
|
+
local log; log=$(mktemp)
|
|
2337
|
+
if ! ( cd "$dir" && cargo build --release ) >"$log" 2>&1; then
|
|
2338
|
+
say "tuicr: build failed in $dir"
|
|
2339
|
+
# Swallowing cargo's output here made a missing toolchain look identical
|
|
2340
|
+
# to a compile error.
|
|
2341
|
+
grep -E '^(error|warning: unused)' "$log" | head -5 | sed 's/^/ /'
|
|
2342
|
+
tail -3 "$log" | sed 's/^/ /'
|
|
2343
|
+
rm -f "$log"; return 1
|
|
2344
|
+
fi
|
|
2345
|
+
rm -f "$log"
|
|
2346
|
+
mkdir -p "$BIN"
|
|
2347
|
+
# mv, not cp: cp fails with "Text file busy" when tuicr is running.
|
|
2348
|
+
cp "$dir/target/release/tuicr" "$BIN/tuicr.new" && mv -f "$BIN/tuicr.new" "$BIN/tuicr"
|
|
2349
|
+
say "tuicr built from $branch and installed to $BIN/tuicr"
|
|
2350
|
+
}
|
|
2351
|
+
|
|
2352
|
+
apply_tuicr() {
|
|
2353
|
+
local from_fork; from_fork=$(cfg_get tuicr.build_from_fork false)
|
|
2354
|
+
if ! have tuicr; then
|
|
2355
|
+
if [ "$from_fork" = "true" ]; then
|
|
2356
|
+
build_tuicr_fork || return 0
|
|
2357
|
+
else
|
|
2358
|
+
install_binary tuicr agavra/tuicr x86_64-unknown-linux-gnu || return 0
|
|
2359
|
+
fi
|
|
2360
|
+
fi
|
|
2361
|
+
have tuicr || { say "tuicr not installed, skipping config"; return 0; }
|
|
2362
|
+
|
|
2363
|
+
mkdir -p "$HOME/.config/tuicr"
|
|
2364
|
+
# show_file_line_stats and file_list_width only exist in the fork build.
|
|
2365
|
+
# Writing them against a stock release makes tuicr warn on every start.
|
|
2366
|
+
ACSTACK_ARGS="$from_fork|$(cfg_get tuicr.file_list_width 30)|$(cfg_get tuicr.show_file_line_stats true)|$(cfg_get tuicr.appearance "")|$BEGIN|$END" \
|
|
2367
|
+
py "$HOME/.config/tuicr/config.toml" <<'PY'
|
|
2368
|
+
import os, re, sys
|
|
2369
|
+
path = sys.argv[1]
|
|
2370
|
+
from_fork, width, stats, appearance, begin, end = os.environ["ACSTACK_ARGS"].split("|")
|
|
2371
|
+
text = open(path).read() if os.path.isfile(path) else ""
|
|
2372
|
+
text = re.sub(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", "", text, flags=re.S)
|
|
2373
|
+
|
|
2374
|
+
block = [begin]
|
|
2375
|
+
managed = set()
|
|
2376
|
+
if from_fork == "true":
|
|
2377
|
+
block += [
|
|
2378
|
+
"# These two keys exist only in the fork build. See the tuicr section",
|
|
2379
|
+
"# of ~/.config/acstack/terminal-workspace.yaml.",
|
|
2380
|
+
f"show_file_line_stats = {stats}",
|
|
2381
|
+
f"file_list_width = {width}",
|
|
2382
|
+
"# A local build must not be replaced by a stock release.",
|
|
2383
|
+
"no_update_check = true",
|
|
2384
|
+
]
|
|
2385
|
+
managed |= {"show_file_line_stats", "file_list_width", "no_update_check"}
|
|
2386
|
+
else:
|
|
2387
|
+
block += ["# Stock release: the fork-only keys are omitted so tuicr does not",
|
|
2388
|
+
"# warn about unknown config keys at startup."]
|
|
2389
|
+
# `system` asks the terminal for its background colour over OSC 11. Under
|
|
2390
|
+
# `herdr --remote` nothing answers, so tuicr falls back to a desktop setting
|
|
2391
|
+
# that a headless machine reports as light, and paints dark text on a black
|
|
2392
|
+
# terminal. Pinning the value sidesteps the whole detection path.
|
|
2393
|
+
if appearance:
|
|
2394
|
+
block += ["# Pinned rather than detected. See the tuicr section of",
|
|
2395
|
+
"# ~/.config/acstack/terminal-workspace.yaml.",
|
|
2396
|
+
f'appearance = "{appearance}"']
|
|
2397
|
+
managed.add("appearance")
|
|
2398
|
+
block.append(end)
|
|
2399
|
+
|
|
2400
|
+
# TOML forbids a duplicate key, so a value the user set by hand before this
|
|
2401
|
+
# script managed it would make the merged file unparseable and tuicr would
|
|
2402
|
+
# reject the lot. Drop only the keys this run writes, and the comment block
|
|
2403
|
+
# that introduced each one: the setting alone leaves a heading over nothing.
|
|
2404
|
+
kept, pending, cut = [], [], None
|
|
2405
|
+
for ln in text.splitlines():
|
|
2406
|
+
stripped = ln.strip()
|
|
2407
|
+
if stripped.startswith("#") or not stripped:
|
|
2408
|
+
pending.append(ln)
|
|
2409
|
+
continue
|
|
2410
|
+
# Every key this script writes is top level, so the first table header ends
|
|
2411
|
+
# the search. A key of the same name inside a table is a different key.
|
|
2412
|
+
if cut is None and stripped.startswith("["):
|
|
2413
|
+
# Before the comments introducing that header, not after them.
|
|
2414
|
+
cut = len(kept)
|
|
2415
|
+
if cut is None and "=" in ln and ln.split("=", 1)[0].strip().strip('"') in managed:
|
|
2416
|
+
pending = []
|
|
2417
|
+
continue
|
|
2418
|
+
kept.extend(pending)
|
|
2419
|
+
pending = []
|
|
2420
|
+
kept.append(ln)
|
|
2421
|
+
kept.extend(pending)
|
|
2422
|
+
|
|
2423
|
+
# The block holds top-level keys, so it goes above the first table header
|
|
2424
|
+
# rather than at the end of the file. Appended after a `[export]` or
|
|
2425
|
+
# `[forge]` section, every key in it would belong to that section instead:
|
|
2426
|
+
# tuicr would warn about an unknown key there and never see the setting, and
|
|
2427
|
+
# a name the section already uses makes the file invalid outright.
|
|
2428
|
+
if cut is None:
|
|
2429
|
+
text = "\n".join(kept).rstrip("\n")
|
|
2430
|
+
text = (text + "\n\n" if text else "") + "\n".join(block) + "\n"
|
|
2431
|
+
else:
|
|
2432
|
+
head = "\n".join(kept[:cut]).rstrip("\n")
|
|
2433
|
+
text = ((head + "\n\n" if head else "") + "\n".join(block) + "\n\n"
|
|
2434
|
+
+ "\n".join(kept[cut:]).strip("\n") + "\n")
|
|
2435
|
+
open(path, "w").write(text)
|
|
2436
|
+
PY
|
|
2437
|
+
say "tuicr config written"
|
|
2438
|
+
}
|
|
2439
|
+
|
|
2440
|
+
# The sidebar draws a workspace's position in herdr's stored list, but
|
|
2441
|
+
# prefix+1..9 targets its row in the grouped sidebar. Creating or removing a
|
|
2442
|
+
# worktree moves one and not the other, so the numbers you read stop being the
|
|
2443
|
+
# numbers you press. This links a tiny herdr plugin that reruns the alignment
|
|
2444
|
+
# on every event that can move a row, so it never needs remembering.
|
|
2445
|
+
apply_herdr_numbers() {
|
|
2446
|
+
have herdr || return 0
|
|
2447
|
+
[ "$(cfg_get herdr.renumber_plugin true)" = "true" ] || return 0
|
|
2448
|
+
|
|
2449
|
+
local dir="$HOME/.config/acstack/herdr-numbers"
|
|
2450
|
+
mkdir -p "$dir"
|
|
2451
|
+
# An absolute command: herdr runs argv without a shell and resolves relative
|
|
2452
|
+
# commands from the plugin root, not from PATH.
|
|
2453
|
+
cat > "$dir/herdr-plugin.toml" <<H
|
|
2454
|
+
id = "acstack.herdr-numbers"
|
|
2455
|
+
name = "ACStack workspace numbers"
|
|
2456
|
+
version = "1.0.0"
|
|
2457
|
+
min_herdr_version = "0.8.0"
|
|
2458
|
+
description = "Keep the sidebar numbers matching prefix+1..9"
|
|
2459
|
+
platforms = ["linux", "macos", "windows"]
|
|
2460
|
+
|
|
2461
|
+
# Every event that can move a sidebar row. workspace.moved and
|
|
2462
|
+
# workspace.reordered are deliberately absent: the realignment emits both, so
|
|
2463
|
+
# hooking them would loop forever. The helper is idempotent, so the overlap
|
|
2464
|
+
# between the workspace and worktree events costs nothing.
|
|
2465
|
+
[[events]]
|
|
2466
|
+
on = "workspace.created"
|
|
2467
|
+
command = ["$BIN/acstack-herdr-renumber"]
|
|
2468
|
+
|
|
2469
|
+
[[events]]
|
|
2470
|
+
on = "workspace.closed"
|
|
2471
|
+
command = ["$BIN/acstack-herdr-renumber"]
|
|
2472
|
+
|
|
2473
|
+
[[events]]
|
|
2474
|
+
on = "worktree.created"
|
|
2475
|
+
command = ["$BIN/acstack-herdr-renumber"]
|
|
2476
|
+
|
|
2477
|
+
[[events]]
|
|
2478
|
+
on = "worktree.opened"
|
|
2479
|
+
command = ["$BIN/acstack-herdr-renumber"]
|
|
2480
|
+
|
|
2481
|
+
[[events]]
|
|
2482
|
+
on = "worktree.removed"
|
|
2483
|
+
command = ["$BIN/acstack-herdr-renumber"]
|
|
2484
|
+
H
|
|
2485
|
+
|
|
2486
|
+
if herdr plugin list 2>/dev/null | grep -q "acstack.herdr-numbers"; then
|
|
2487
|
+
say "herdr numbers plugin already linked"
|
|
2488
|
+
elif herdr plugin link "$dir" >/dev/null 2>&1; then
|
|
2489
|
+
say "herdr numbers plugin linked, sidebar numbers stay aligned"
|
|
2490
|
+
else
|
|
2491
|
+
say "herdr plugin link failed; run acstack-herdr-renumber by hand"
|
|
2492
|
+
fi
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
apply_yazi() {
|
|
2496
|
+
[ "$(cfg_get yazi.enabled true)" = "true" ] || return 0
|
|
2497
|
+
if ! have yazi; then
|
|
2498
|
+
install_binary yazi sxyazi/yazi x86_64-unknown-linux-gnu || return 0
|
|
2499
|
+
install_binary ya sxyazi/yazi x86_64-unknown-linux-gnu || true
|
|
2500
|
+
fi
|
|
2501
|
+
have yazi || { say "yazi not installed, skipping config"; return 0; }
|
|
2502
|
+
|
|
2503
|
+
# piper turns any shell command into a previewer, which is how acstack-md
|
|
2504
|
+
# gets to draw markdown in the preview pane.
|
|
2505
|
+
if have ya && ! ya pkg list 2>/dev/null | grep -q "plugins:piper"; then
|
|
2506
|
+
ya pkg add yazi-rs/plugins:piper >/dev/null 2>&1 \
|
|
2507
|
+
&& say "yazi piper plugin installed" \
|
|
2508
|
+
|| say "piper install failed; markdown will preview as plain text"
|
|
2509
|
+
fi
|
|
2510
|
+
|
|
2511
|
+
mkdir -p "$HOME/.config/yazi"
|
|
2512
|
+
ACSTACK_ARGS="$(cfg_get yazi.show_hidden true)|$BEGIN|$END" \
|
|
2513
|
+
py "$HOME/.config/yazi/yazi.toml" <<'PY'
|
|
2514
|
+
import os, re, sys
|
|
2515
|
+
path = sys.argv[1]
|
|
2516
|
+
hidden, begin, end = os.environ["ACSTACK_ARGS"].split("|")
|
|
2517
|
+
text = open(path).read() if os.path.isfile(path) else ""
|
|
2518
|
+
# Drop any previous managed region, both to stay idempotent and so what is
|
|
2519
|
+
# left to inspect below is exactly the config the user wrote.
|
|
2520
|
+
text = re.sub(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", "", text, flags=re.S)
|
|
2521
|
+
|
|
2522
|
+
# Every namespace the block writes into. TOML forbids declaring any of them
|
|
2523
|
+
# twice, and yazi answers one duplicate by throwing the entire config away and
|
|
2524
|
+
# falling back to presets. So a namespace the user already occupies is theirs:
|
|
2525
|
+
# the block gives that piece up rather than cost them every other setting they
|
|
2526
|
+
# have. Deciding which are taken is a question about the parsed document, not
|
|
2527
|
+
# about the text: [ mgr ], [ "mgr" ], [ 'mgr' ] and mgr.show_hidden = false are
|
|
2528
|
+
# all the same table, and only a parser knows that.
|
|
2529
|
+
NAMESPACES = ("mgr", "opener.markdown", "plugin.prepend_previewers", "open.prepend_rules")
|
|
2530
|
+
|
|
2531
|
+
try:
|
|
2532
|
+
import tomllib
|
|
2533
|
+
except ImportError: # tomllib is 3.11 and newer
|
|
2534
|
+
tomllib = None
|
|
2535
|
+
|
|
2536
|
+
|
|
2537
|
+
def claimed(cfg):
|
|
2538
|
+
"""Which of NAMESPACES the user's own config already occupies."""
|
|
2539
|
+
if tomllib is None:
|
|
2540
|
+
# No parser to reason with, so read the text conservatively: name the
|
|
2541
|
+
# top-level table wherever TOML could open one, as a header or as a
|
|
2542
|
+
# key of its own. That concedes namespaces which would have been safe
|
|
2543
|
+
# to write, and each of those costs one setting; guessing the other
|
|
2544
|
+
# way costs the file. One thing text cannot see is that the config is
|
|
2545
|
+
# broken already, so unlike the parsed path below this branch does not
|
|
2546
|
+
# concede everything for a file that never parsed.
|
|
2547
|
+
found = set()
|
|
2548
|
+
for ns in NAMESPACES:
|
|
2549
|
+
root = re.escape(ns.split(".")[0])
|
|
2550
|
+
# A key may be bare, a basic string or a literal string, and all
|
|
2551
|
+
# three name the same table: [mgr], ["mgr"] and ['mgr'] are one.
|
|
2552
|
+
name = r"""(?:%s|"%s"|'%s')""" % (root, root, root)
|
|
2553
|
+
header = r'\[\[?\s*%s\s*[.\]]' % name
|
|
2554
|
+
key = r'%s\s*[.=]' % name
|
|
2555
|
+
if re.search(r"(?m)^\s*(?:%s|%s)" % (header, key), cfg):
|
|
2556
|
+
found.add(ns)
|
|
2557
|
+
return found
|
|
2558
|
+
try:
|
|
2559
|
+
tomllib.loads(cfg)
|
|
2560
|
+
except tomllib.TOMLDecodeError:
|
|
2561
|
+
# Their config does not parse as it stands. We cannot tell what it
|
|
2562
|
+
# claims, and repairing it is not ours to do, so concede everything:
|
|
2563
|
+
# the file ends up exactly as broken as it arrived, never more.
|
|
2564
|
+
return set(NAMESPACES)
|
|
2565
|
+
found = set()
|
|
2566
|
+
for ns in NAMESPACES:
|
|
2567
|
+
# Whether a namespace is free is not "is the key there". TOML also
|
|
2568
|
+
# refuses to reopen an inline table and to overwrite a scalar, so
|
|
2569
|
+
# opener = { text = [...] } and opener = "nope" both have no
|
|
2570
|
+
# opener.markdown key and are both fatal to write under. tomllib
|
|
2571
|
+
# hands an inline table back as an ordinary dict, so no walk over the
|
|
2572
|
+
# parsed document can tell those apart from a table that is still
|
|
2573
|
+
# open. Put the question to the parser instead, in the exact form the
|
|
2574
|
+
# block would write it: append that header to their config and see
|
|
2575
|
+
# whether the whole thing still parses. Whatever it refuses is theirs.
|
|
2576
|
+
probe = "[mgr]" if ns == "mgr" else "[[%s]]" % ns
|
|
2577
|
+
try:
|
|
2578
|
+
tomllib.loads(cfg + "\n" + probe + "\n")
|
|
2579
|
+
except tomllib.TOMLDecodeError:
|
|
2580
|
+
found.add(ns)
|
|
2581
|
+
return found
|
|
2582
|
+
|
|
2583
|
+
|
|
2584
|
+
taken = claimed(text)
|
|
2585
|
+
parts = []
|
|
2586
|
+
|
|
2587
|
+
if "plugin.prepend_previewers" not in taken:
|
|
2588
|
+
parts.append("""
|
|
2589
|
+
[[plugin.prepend_previewers]]
|
|
2590
|
+
url = "*.md"
|
|
2591
|
+
run = 'piper -- COLUMNS=$w acstack-md --preview "$1"'
|
|
2592
|
+
|
|
2593
|
+
[[plugin.prepend_previewers]]
|
|
2594
|
+
url = "*.markdown"
|
|
2595
|
+
run = 'piper -- COLUMNS=$w acstack-md --preview "$1"'
|
|
2596
|
+
""")
|
|
2597
|
+
|
|
2598
|
+
if "opener.markdown" not in taken:
|
|
2599
|
+
# An array of tables rather than a markdown key under [opener]: a second
|
|
2600
|
+
# [opener] table would be a duplicate, while a subtable of one the user
|
|
2601
|
+
# has already declared is legal.
|
|
2602
|
+
parts.append("""
|
|
2603
|
+
# The preview pane is narrow, so a wide diagram shows its placeholder there.
|
|
2604
|
+
# Enter opens the same renderer full width, where more of them fit.
|
|
2605
|
+
#
|
|
2606
|
+
# %s is what yazi substitutes for the files being opened, already quoted. Not
|
|
2607
|
+
# "$@": an opener template is run as `sh -c '<run>'` with nothing after it, so
|
|
2608
|
+
# $0 is "sh" and $@ is empty - measured on yazi 26.8.15, where "$@" left
|
|
2609
|
+
# acstack-md with no file at all and Enter did nothing but print its usage.
|
|
2610
|
+
[[opener.markdown]]
|
|
2611
|
+
run = 'acstack-md %s'
|
|
2612
|
+
block = true
|
|
2613
|
+
desc = "Read (acstack-md)"
|
|
2614
|
+
|
|
2615
|
+
# Rendered markdown has no line number to quote back in a review, so the same
|
|
2616
|
+
# file is also readable as its own source. Second, not first: it answers a
|
|
2617
|
+
# narrower question than reading does, and still a readier one than editing.
|
|
2618
|
+
# Enter takes the first entry, so O is the key that offers this one.
|
|
2619
|
+
[[opener.markdown]]
|
|
2620
|
+
run = 'acstack-md --numbered %s'
|
|
2621
|
+
block = true
|
|
2622
|
+
desc = "Read with line numbers"
|
|
2623
|
+
|
|
2624
|
+
[[opener.markdown]]
|
|
2625
|
+
run = '${EDITOR:-vi} %s'
|
|
2626
|
+
block = true
|
|
2627
|
+
desc = "Edit"
|
|
2628
|
+
""")
|
|
2629
|
+
|
|
2630
|
+
if "open.prepend_rules" not in taken:
|
|
2631
|
+
# Worth writing even when the opener above was skipped: the rules point
|
|
2632
|
+
# markdown files at whichever opener.markdown ends up in the file.
|
|
2633
|
+
parts.append("""
|
|
2634
|
+
[[open.prepend_rules]]
|
|
2635
|
+
url = "*.md"
|
|
2636
|
+
use = "markdown"
|
|
2637
|
+
|
|
2638
|
+
[[open.prepend_rules]]
|
|
2639
|
+
url = "*.markdown"
|
|
2640
|
+
use = "markdown"
|
|
2641
|
+
""")
|
|
2642
|
+
|
|
2643
|
+
if "mgr" not in taken:
|
|
2644
|
+
parts.append(f"[mgr]\nshow_hidden = {hidden}")
|
|
2645
|
+
|
|
2646
|
+
# The markers go down even when every piece was conceded, so the shell below
|
|
2647
|
+
# can still read the region back and see what is missing from it.
|
|
2648
|
+
block = begin + "\n" + "\n\n".join(p.strip("\n") for p in parts) + "\n" + end
|
|
2649
|
+
text = text.rstrip("\n")
|
|
2650
|
+
open(path, "w").write((text + "\n\n" if text else "") + block + "\n")
|
|
2651
|
+
PY
|
|
2652
|
+
# A setting the block gave up to the user's own config is simply absent from
|
|
2653
|
+
# what was written, so read the region back rather than let it go unsaid.
|
|
2654
|
+
local written; written="$(sed -n "/$BEGIN/,/$END/p" "$HOME/.config/yazi/yazi.toml")"
|
|
2655
|
+
case "$written" in *"[[plugin.prepend_previewers]]"*) ;; *)
|
|
2656
|
+
say "yazi: your yazi.toml already sets plugin.prepend_previewers, so the"
|
|
2657
|
+
say " markdown previewer was left alone. Add a *.md entry there to"
|
|
2658
|
+
say " preview markdown with acstack-md." ;;
|
|
2659
|
+
esac
|
|
2660
|
+
case "$written" in *"[[opener.markdown]]"*) ;; *)
|
|
2661
|
+
say "yazi: your yazi.toml already opens markdown its own way, so it was left"
|
|
2662
|
+
say " alone. Add acstack-md to that opener to read markdown with it." ;;
|
|
2663
|
+
esac
|
|
2664
|
+
case "$written" in *"[[open.prepend_rules]]"*) ;; *)
|
|
2665
|
+
say "yazi: your yazi.toml already sets open.prepend_rules, so the markdown"
|
|
2666
|
+
say " rules were left alone. Add *.md and *.markdown entries there"
|
|
2667
|
+
say " pointing at the markdown opener." ;;
|
|
2668
|
+
esac
|
|
2669
|
+
case "$written" in *show_hidden*) ;; *)
|
|
2670
|
+
say "yazi: your yazi.toml already claims mgr itself, so show_hidden was left"
|
|
2671
|
+
say " alone. Set it there yourself to show hidden files." ;;
|
|
2672
|
+
esac
|
|
2673
|
+
# The keymap is its own file, and prepend_keymap has two spellings: an array
|
|
2674
|
+
# of tables, which is additive and safe to extend, and an inline array under
|
|
2675
|
+
# [mgr], which is one key that TOML forbids declaring twice. Only the second
|
|
2676
|
+
# collides with the entry below, and telling them apart takes the text - both
|
|
2677
|
+
# spellings parse to the same list.
|
|
2678
|
+
ACSTACK_ARGS="$(cfg_get yazi.browser_key "b")|$(cfg_get yazi.line_numbers_key "#")|$(cfg_get yazi.download_key "D")|$(cfg_get yazi.download_target "")|$(cfg_get yazi.edit_key "e")|$BEGIN|$END" \
|
|
2679
|
+
py "$HOME/.config/yazi/keymap.toml" <<'PY'
|
|
2680
|
+
import os, re, sys
|
|
2681
|
+
|
|
2682
|
+
path = sys.argv[1]
|
|
2683
|
+
key, numkey, dlkey, dltarget, editkey, begin, end = os.environ["ACSTACK_ARGS"].split("|")
|
|
2684
|
+
text = open(path).read() if os.path.isfile(path) else ""
|
|
2685
|
+
# Drop any previous managed region, both to stay idempotent and so what is
|
|
2686
|
+
# left to inspect below is exactly the keymap the user wrote.
|
|
2687
|
+
text = re.sub(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", "", text, flags=re.S)
|
|
2688
|
+
|
|
2689
|
+
try:
|
|
2690
|
+
import tomllib
|
|
2691
|
+
except ImportError: # tomllib is 3.11 and newer
|
|
2692
|
+
tomllib = None
|
|
2693
|
+
|
|
2694
|
+
|
|
2695
|
+
def claimed(t):
|
|
2696
|
+
"""True when the user's keymap declares prepend_keymap as an inline array
|
|
2697
|
+
under [mgr] - the one spelling our own entry cannot sit beside."""
|
|
2698
|
+
if not re.search(r"^\s*prepend_keymap\s*=", t, flags=re.M):
|
|
2699
|
+
return False
|
|
2700
|
+
if tomllib is None:
|
|
2701
|
+
# No parser to say which table that assignment sits in, so assume the
|
|
2702
|
+
# worst. A conceded binding costs one key; a collision costs the whole
|
|
2703
|
+
# keymap, because yazi answers invalid TOML by falling back to presets.
|
|
2704
|
+
return True
|
|
2705
|
+
try:
|
|
2706
|
+
return "prepend_keymap" in (tomllib.loads(t).get("mgr") or {})
|
|
2707
|
+
except Exception:
|
|
2708
|
+
return True
|
|
2709
|
+
|
|
2710
|
+
|
|
2711
|
+
CLAIMED = {k for k in (key, numkey, editkey) if k}
|
|
2712
|
+
if dltarget and dlkey:
|
|
2713
|
+
CLAIMED.add(dlkey)
|
|
2714
|
+
|
|
2715
|
+
|
|
2716
|
+
def drop_claimed(t):
|
|
2717
|
+
"""Remove any prepend_keymap block the user wrote on a key this script is
|
|
2718
|
+
about to bind.
|
|
2719
|
+
|
|
2720
|
+
yazi accepts both, lists both in its help, and runs one of them. So a
|
|
2721
|
+
binding written by hand before this script managed the key leaves two
|
|
2722
|
+
entries saying different things and no way to tell which one fires. Both
|
|
2723
|
+
table spellings, because `manager` is what yazi called `mgr` before the
|
|
2724
|
+
rename and an older keymap still says it.
|
|
2725
|
+
"""
|
|
2726
|
+
out, block = [], None
|
|
2727
|
+
|
|
2728
|
+
def bound_key(lines):
|
|
2729
|
+
"""The single key this block binds, or None.
|
|
2730
|
+
|
|
2731
|
+
yazi spells `on` four ways: a double-quoted scalar, a single-quoted
|
|
2732
|
+
one, a one-element array, and any of those with a trailing comment.
|
|
2733
|
+
Reading only the first spelling leaves the other three in place, which
|
|
2734
|
+
is the state this function exists to prevent. A longer array is a
|
|
2735
|
+
multi-key sequence and never collides with a single key.
|
|
2736
|
+
"""
|
|
2737
|
+
body = "\n".join(lines[1:])
|
|
2738
|
+
if tomllib is not None:
|
|
2739
|
+
try:
|
|
2740
|
+
on = tomllib.loads(body).get("on")
|
|
2741
|
+
except Exception:
|
|
2742
|
+
on = ... # unparseable, so fall through to the regex
|
|
2743
|
+
if on is not ...:
|
|
2744
|
+
if isinstance(on, str):
|
|
2745
|
+
return on
|
|
2746
|
+
if isinstance(on, list) and len(on) == 1 and isinstance(on[0], str):
|
|
2747
|
+
return on[0]
|
|
2748
|
+
return None
|
|
2749
|
+
m = re.search(r"""(?m)^\s*on\s*=\s*\[?\s*(?:"([^"]*)"|'([^']*)')""", body)
|
|
2750
|
+
return (m.group(1) if m.group(1) is not None else m.group(2)) if m else None
|
|
2751
|
+
|
|
2752
|
+
def close():
|
|
2753
|
+
nonlocal block
|
|
2754
|
+
if block is None:
|
|
2755
|
+
return
|
|
2756
|
+
# Trailing blank lines belong to whatever comes next, not to the
|
|
2757
|
+
# binding, so they stay even when the binding goes.
|
|
2758
|
+
tail = []
|
|
2759
|
+
while block and not block[-1].strip():
|
|
2760
|
+
tail.insert(0, block.pop())
|
|
2761
|
+
if bound_key(block) not in CLAIMED:
|
|
2762
|
+
out.extend(block)
|
|
2763
|
+
out.extend(tail)
|
|
2764
|
+
block = None
|
|
2765
|
+
|
|
2766
|
+
for ln in t.splitlines():
|
|
2767
|
+
if re.match(r"^\s*\[\[\s*(?:mgr|manager)\.prepend_keymap\s*\]\]\s*$", ln):
|
|
2768
|
+
close()
|
|
2769
|
+
block = [ln]
|
|
2770
|
+
continue
|
|
2771
|
+
if block is not None:
|
|
2772
|
+
# Any other table header ends this binding and belongs to what
|
|
2773
|
+
# follows, so it is never dropped with the block.
|
|
2774
|
+
if ln.lstrip().startswith("["):
|
|
2775
|
+
close()
|
|
2776
|
+
out.append(ln)
|
|
2777
|
+
continue
|
|
2778
|
+
block.append(ln)
|
|
2779
|
+
continue
|
|
2780
|
+
out.append(ln)
|
|
2781
|
+
close()
|
|
2782
|
+
return "\n".join(out)
|
|
2783
|
+
|
|
2784
|
+
|
|
2785
|
+
parts = []
|
|
2786
|
+
if not claimed(text):
|
|
2787
|
+
# A binding conceded below is one this script never writes, so the user's
|
|
2788
|
+
# own must survive. Only strip when the bindings are actually going in.
|
|
2789
|
+
text = drop_claimed(text)
|
|
2790
|
+
# %h is the hovered file. $0 and $@ are not: yazi runs the template through
|
|
2791
|
+
# a shell, where $0 names the shell itself and $@ is empty - both measured,
|
|
2792
|
+
# and both would open the browser on nothing at all.
|
|
2793
|
+
#
|
|
2794
|
+
# %h stays bare, here and in every binding below. yazi shell-quotes it
|
|
2795
|
+
# itself, so quotes around it end up inside the path and nothing is found.
|
|
2796
|
+
#
|
|
2797
|
+
# --block because the delivery has something to say. On the bridge it is a
|
|
2798
|
+
# line of confirmation; on every other route it is a link, and a served
|
|
2799
|
+
# page that has to stay up. Detached, all of that goes nowhere.
|
|
2800
|
+
parts.append(
|
|
2801
|
+
"[[mgr.prepend_keymap]]\n"
|
|
2802
|
+
f'on = "{key}"\n'
|
|
2803
|
+
"""run = 'shell --block -- acstack-md --browser %h'\n"""
|
|
2804
|
+
'desc = "Open in the browser you are sitting at"'
|
|
2805
|
+
)
|
|
2806
|
+
# Flipping the flag changes nothing a reader can see: the pane already
|
|
2807
|
+
# holds the view it drew before the key was pressed. So the binding is two
|
|
2808
|
+
# actions, and peek redraws it.
|
|
2809
|
+
#
|
|
2810
|
+
# --block, though the toggle has nothing to say. Detached, yazi spawns it
|
|
2811
|
+
# and runs peek immediately, and the pane is redrawn while the flag is
|
|
2812
|
+
# still whatever it was - a race the reader settles by pressing the key
|
|
2813
|
+
# twice. Blocking costs a frame and wins it outright.
|
|
2814
|
+
parts.append(
|
|
2815
|
+
"[[mgr.prepend_keymap]]\n"
|
|
2816
|
+
f'on = "{numkey}"\n'
|
|
2817
|
+
"run = [ 'shell --block -- acstack-md --toggle-line-numbers', 'peek --force' ]\n"
|
|
2818
|
+
'desc = "Preview markdown as source with line numbers"'
|
|
2819
|
+
)
|
|
2820
|
+
# `e` edits, everywhere. tuicr's diff and file tree both put the editor on
|
|
2821
|
+
# `e`, so yazi matching it means one key means edit across the workspace.
|
|
2822
|
+
# `o` is left alone: it opens by rule, which for markdown is the reader.
|
|
2823
|
+
#
|
|
2824
|
+
# ${EDITOR:-vi} rather than a fixed editor, the same expansion the opener
|
|
2825
|
+
# in yazi.toml uses, so both paths land in whatever the user actually runs.
|
|
2826
|
+
parts.append(
|
|
2827
|
+
"[[mgr.prepend_keymap]]\n"
|
|
2828
|
+
f'on = "{editkey}"\n'
|
|
2829
|
+
"""run = 'shell --block -- ${EDITOR:-vi} %h'\n"""
|
|
2830
|
+
'desc = "Edit in $EDITOR"'
|
|
2831
|
+
)
|
|
2832
|
+
# Taildrop is Tailscale's file send. It is the one route off a headless
|
|
2833
|
+
# machine that needs nothing installed on the machine the user sits at,
|
|
2834
|
+
# and no inbound port there either.
|
|
2835
|
+
#
|
|
2836
|
+
# %h, the hovered file, for the same reason the browser key uses it. %* is
|
|
2837
|
+
# not a keybinding placeholder at all - yazi passes it through untouched,
|
|
2838
|
+
# and tailscale then reports `open %*: no such file or directory`.
|
|
2839
|
+
# Measured on yazi 26.8.15.
|
|
2840
|
+
#
|
|
2841
|
+
# --block because a send reports progress, and a refused one reports why.
|
|
2842
|
+
if dltarget:
|
|
2843
|
+
parts.append(
|
|
2844
|
+
"[[mgr.prepend_keymap]]\n"
|
|
2845
|
+
f'on = "{dlkey}"\n'
|
|
2846
|
+
f"""run = 'shell --block -- tailscale file cp %h {dltarget}:'\n"""
|
|
2847
|
+
f'desc = "Send this file to {dltarget} over Taildrop"'
|
|
2848
|
+
)
|
|
2849
|
+
|
|
2850
|
+
# The markers go down even when the binding was conceded, so the shell below
|
|
2851
|
+
# can read the region back and see what is missing from it.
|
|
2852
|
+
block = begin + "\n" + "\n\n".join(parts) + ("\n" if parts else "") + end
|
|
2853
|
+
text = text.rstrip("\n")
|
|
2854
|
+
open(path, "w").write((text + "\n\n" if text else "") + block + "\n")
|
|
2855
|
+
PY
|
|
2856
|
+
local kmwritten; kmwritten="$(sed -n "/$BEGIN/,/$END/p" "$HOME/.config/yazi/keymap.toml")"
|
|
2857
|
+
case "$kmwritten" in *prepend_keymap*) ;; *)
|
|
2858
|
+
say "yazi: your keymap.toml already sets mgr.prepend_keymap as an inline"
|
|
2859
|
+
say " array, which these bindings cannot sit beside. Add them there"
|
|
2860
|
+
say " yourself: shell --block -- acstack-md --browser %h"
|
|
2861
|
+
say " and: [ 'shell --block -- acstack-md --toggle-line-numbers', 'peek --force' ]"
|
|
2862
|
+
say " and: shell --block -- \${EDITOR:-vi} %h" ;;
|
|
2863
|
+
esac
|
|
2864
|
+
# The download key is written from the config alone, so it can be wrong in
|
|
2865
|
+
# three ways the config cannot see. Name whichever one holds, rather than
|
|
2866
|
+
# leaving the user to read `status code: 1` out of a yazi popup.
|
|
2867
|
+
local dltarget; dltarget="$(cfg_get yazi.download_target "")"
|
|
2868
|
+
if [ -n "$dltarget" ]; then
|
|
2869
|
+
if ! have tailscale; then
|
|
2870
|
+
say "yazi: download_target names $dltarget, but this machine has no"
|
|
2871
|
+
say " tailscale. Install it, or clear the key to drop the binding."
|
|
2872
|
+
elif ! tailscale file cp --targets >/dev/null 2>&1; then
|
|
2873
|
+
say "yazi: tailscale refuses file access to this user account. Run once:"
|
|
2874
|
+
say " sudo tailscale set --operator=\$USER"
|
|
2875
|
+
elif ! tailscale file cp --targets 2>/dev/null \
|
|
2876
|
+
| awk -F'\t' -v t="$dltarget" '$2 == t { f = 1 } END { exit !f }'; then
|
|
2877
|
+
say "yazi: $dltarget is not a Taildrop target of this machine. Taildrop"
|
|
2878
|
+
say " only sends between devices one Tailscale account owns on one"
|
|
2879
|
+
say " tailnet. Check both ends with: tailscale file cp --targets"
|
|
2880
|
+
fi
|
|
2881
|
+
fi
|
|
2882
|
+
say "yazi config written"
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
apply_markdown() {
|
|
2886
|
+
[ "$(cfg_get markdown.enabled true)" = "true" ] || return 0
|
|
2887
|
+
install_binary mermaid-ascii AlexanderGrooff/mermaid-ascii Linux_x86_64
|
|
2888
|
+
install_binary glow charmbracelet/glow Linux_x86_64
|
|
2889
|
+
# Vendored so the preview page pulls nothing from a CDN: it works offline,
|
|
2890
|
+
# and on a managed laptop there is no third-party fetch to explain.
|
|
2891
|
+
local share="$HOME/.local/share/acstack"
|
|
2892
|
+
mkdir -p "$share"
|
|
2893
|
+
if [ ! -s "$share/mermaid.min.js" ]; then
|
|
2894
|
+
curl -fsSL -o "$share/mermaid.min.js" \
|
|
2895
|
+
"https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js" \
|
|
2896
|
+
&& say "mermaid.js vendored to $share" \
|
|
2897
|
+
|| say "mermaid.js download failed, the preview will fall back to a CDN"
|
|
2898
|
+
fi
|
|
2899
|
+
python3 -c 'import markdown' 2>/dev/null \
|
|
2900
|
+
|| say "acstack-md --serve needs python markdown: pip install --user markdown"
|
|
2901
|
+
have chafa || say "optional: apt install chafa, to draw images as text"
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
# The plugin file's name is ours by convention alone, and the directory around
|
|
2905
|
+
# it is the user's. A file of that name this script did not write is theirs,
|
|
2906
|
+
# so apply must not overwrite it and disable must not delete it. It says so
|
|
2907
|
+
# where it refuses, so both paths report it the same way.
|
|
2908
|
+
nvim_ours() {
|
|
2909
|
+
[ ! -e "$NVIM_PLUGIN" ] && return 0
|
|
2910
|
+
grep -q "$NVIM_MARK" "$NVIM_PLUGIN" 2>/dev/null && return 0
|
|
2911
|
+
say "neovim: $NVIM_PLUGIN was written by hand, left alone"
|
|
2912
|
+
return 1
|
|
2913
|
+
}
|
|
2914
|
+
|
|
2915
|
+
# The dot is appended to LazyVim's lualine section, and LazyVim loads every
|
|
2916
|
+
# file under lua/plugins. So a lualine override the user wrote in a file of
|
|
2917
|
+
# their own does exactly what this component does, and installing on top of it
|
|
2918
|
+
# draws two dots side by side. Print the file that already does it, if any.
|
|
2919
|
+
nvim_dot_elsewhere() {
|
|
2920
|
+
local dir="$HOME/.config/nvim/lua/plugins" f
|
|
2921
|
+
[ -d "$dir" ] || return 1
|
|
2922
|
+
for f in "$dir"/*.lua; do
|
|
2923
|
+
[ -e "$f" ] || continue
|
|
2924
|
+
[ "$f" = "$NVIM_PLUGIN" ] && continue
|
|
2925
|
+
# Both, not either: a lualine override that says nothing about a modified
|
|
2926
|
+
# buffer is not this dot, and neither is a modified check outside lualine.
|
|
2927
|
+
grep -q 'lualine' "$f" 2>/dev/null || continue
|
|
2928
|
+
grep -q 'vim\.bo\.modified' "$f" 2>/dev/null || continue
|
|
2929
|
+
printf '%s\n' "$f"
|
|
2930
|
+
return 0
|
|
2931
|
+
done
|
|
2932
|
+
return 1
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2935
|
+
# Print the first lua file of the user's own that sets a pattern, ignoring
|
|
2936
|
+
# comment lines, since a setting tried and commented out decides nothing.
|
|
2937
|
+
nvim_user_sets() {
|
|
2938
|
+
local f
|
|
2939
|
+
while IFS= read -r f; do
|
|
2940
|
+
grep -v '^[[:space:]]*--' "$f" 2>/dev/null | grep -q "$1" || continue
|
|
2941
|
+
printf '%s\n' "$f"
|
|
2942
|
+
return 0
|
|
2943
|
+
done < <(find "$HOME/.config/nvim" -name '*.lua' ! -path "$NVIM_PLUGIN" 2>/dev/null | sort)
|
|
2944
|
+
return 1
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
# The clipboard part of the plugin file. The comment inside it says why.
|
|
2948
|
+
NVIM_CLIPBOARD_LUA='
|
|
2949
|
+
-- Over SSH, neovim finds no clipboard and :%y+ fails. Copy goes out over
|
|
2950
|
+
-- OSC 52, which asks the terminal you sit at to put the text on its own
|
|
2951
|
+
-- clipboard. Paste replays what this neovim copied last, because Windows
|
|
2952
|
+
-- Terminal refuses OSC 52 reads. Use the terminal paste key for anything else.
|
|
2953
|
+
local function set(v)
|
|
2954
|
+
return v ~= nil and v ~= ""
|
|
2955
|
+
end
|
|
2956
|
+
if not (set(vim.env.DISPLAY) or set(vim.env.WAYLAND_DISPLAY) or set(vim.env.TMUX)
|
|
2957
|
+
or vim.fn.has("mac") == 1 or vim.fn.has("wsl") == 1) then
|
|
2958
|
+
local osc52 = require("vim.ui.clipboard.osc52")
|
|
2959
|
+
local last = {}
|
|
2960
|
+
local function copy(reg)
|
|
2961
|
+
local send = osc52.copy(reg)
|
|
2962
|
+
return function(lines, regtype)
|
|
2963
|
+
last[reg] = { lines, regtype }
|
|
2964
|
+
send(lines)
|
|
2965
|
+
end
|
|
2966
|
+
end
|
|
2967
|
+
local function paste(reg)
|
|
2968
|
+
return function()
|
|
2969
|
+
return last[reg] or { { "" }, "v" }
|
|
2970
|
+
end
|
|
2971
|
+
end
|
|
2972
|
+
vim.g.clipboard = {
|
|
2973
|
+
name = "OSC 52 (acstack)",
|
|
2974
|
+
copy = { ["+"] = copy("+"), ["*"] = copy("*") },
|
|
2975
|
+
paste = { ["+"] = paste("+"), ["*"] = paste("*") },
|
|
2976
|
+
}
|
|
2977
|
+
'
|
|
2978
|
+
# LazyVim empties clipboard over SSH, then restores that value on VeryLazy.
|
|
2979
|
+
# Scheduled, this runs after the restore, so a plain y copies too. It is left
|
|
2980
|
+
# out when the user sets clipboard themselves.
|
|
2981
|
+
NVIM_UNNAMEDPLUS_LUA=' vim.api.nvim_create_autocmd("User", {
|
|
2982
|
+
pattern = "VeryLazy",
|
|
2983
|
+
once = true,
|
|
2984
|
+
callback = function()
|
|
2985
|
+
vim.schedule(function()
|
|
2986
|
+
if vim.o.clipboard == "" then
|
|
2987
|
+
vim.opt.clipboard = "unnamedplus"
|
|
2988
|
+
end
|
|
2989
|
+
end)
|
|
2990
|
+
end,
|
|
2991
|
+
})
|
|
2992
|
+
'
|
|
2993
|
+
|
|
2994
|
+
apply_neovim() {
|
|
2995
|
+
# The one component that is off unless asked for. Every other one writes
|
|
2996
|
+
# files this setup owns end to end, or a marked region inside a config that
|
|
2997
|
+
# invites one. A neovim config is neither: the user built it, so this waits.
|
|
2998
|
+
[ "$(cfg_get neovim.enabled false)" = "true" ] || return 0
|
|
2999
|
+
# The dot appends to LazyVim's own lualine section, and the clipboard undoes
|
|
3000
|
+
# a LazyVim default, so both mean nothing on a config that is not LazyVim.
|
|
3001
|
+
# Two markers, because a LazyVim config never opened has no plugins yet.
|
|
3002
|
+
if ! grep -q LazyVim "$HOME/.config/nvim/lua/config/lazy.lua" 2>/dev/null \
|
|
3003
|
+
&& [ ! -d "$HOME/.local/share/nvim/lazy/LazyVim" ]; then
|
|
3004
|
+
say "neovim: no LazyVim config on this machine, plugin file not written"
|
|
3005
|
+
return 0
|
|
3006
|
+
fi
|
|
3007
|
+
nvim_ours || return 0
|
|
3008
|
+
|
|
3009
|
+
local dot_spec="" clip_lua="" mine
|
|
3010
|
+
if mine="$(nvim_dot_elsewhere)"; then
|
|
3011
|
+
say "neovim: $mine already marks a modified buffer in lualine."
|
|
3012
|
+
# nvim_ours has already run, so a file at this path is one we wrote. A user
|
|
3013
|
+
# who installed first and wrote their own override later would otherwise
|
|
3014
|
+
# keep both, which is the pair of dots this guard exists to prevent.
|
|
3015
|
+
if grep -q 'vim\.bo\.modified' "$NVIM_PLUGIN" 2>/dev/null; then
|
|
3016
|
+
say " The dot this component wrote was removed, so only one is drawn."
|
|
3017
|
+
else
|
|
3018
|
+
say " The dot was not written. Two would be drawn side by side."
|
|
3019
|
+
fi
|
|
3020
|
+
say " Delete $mine to hand the dot back to this component."
|
|
3021
|
+
else
|
|
3022
|
+
local dot; dot="$(cfg_get neovim.unsaved_dot_color '#f38ba8')"
|
|
3023
|
+
dot_spec="$(cat <<H
|
|
3024
|
+
|
|
3025
|
+
-- LazyVim marks an unsaved buffer by recolouring the filename and nothing
|
|
3026
|
+
-- else, because its pretty_path component sets modified_sign = "". A glance
|
|
3027
|
+
-- at the statusline then cannot tell a saved buffer from an unsaved one.
|
|
3028
|
+
-- This appends a dot to the same section, which can.
|
|
3029
|
+
return {
|
|
3030
|
+
{
|
|
3031
|
+
"nvim-lualine/lualine.nvim",
|
|
3032
|
+
opts = function(_, opts)
|
|
3033
|
+
table.insert(opts.sections.lualine_c, {
|
|
3034
|
+
function()
|
|
3035
|
+
return vim.bo.modified and "●" or ""
|
|
3036
|
+
end,
|
|
3037
|
+
color = { fg = "$dot" },
|
|
3038
|
+
})
|
|
3039
|
+
end,
|
|
3040
|
+
},
|
|
3041
|
+
}
|
|
3042
|
+
H
|
|
3043
|
+
)"
|
|
3044
|
+
fi
|
|
3045
|
+
|
|
3046
|
+
if [ "$(cfg_get neovim.osc52_clipboard true)" = "true" ]; then
|
|
3047
|
+
# Whichever lua sets vim.g.clipboard last wins, and the user's own files
|
|
3048
|
+
# load after ours, so ours would only add a forced clipboard option.
|
|
3049
|
+
if mine="$(nvim_user_sets 'g\.clipboard')"; then
|
|
3050
|
+
say "neovim: $mine already sets vim.g.clipboard."
|
|
3051
|
+
if grep -q 'vim\.g\.clipboard' "$NVIM_PLUGIN" 2>/dev/null; then
|
|
3052
|
+
say " The OSC 52 copy this component wrote was removed."
|
|
3053
|
+
else
|
|
3054
|
+
say " The OSC 52 copy was not written."
|
|
3055
|
+
fi
|
|
3056
|
+
else
|
|
3057
|
+
clip_lua="$NVIM_CLIPBOARD_LUA"
|
|
3058
|
+
if mine="$(nvim_user_sets '[.]o[pt]*[.]clipboard')"; then
|
|
3059
|
+
say "neovim: $mine sets clipboard, so y keeps the value set there"
|
|
3060
|
+
else
|
|
3061
|
+
clip_lua="$clip_lua$NVIM_UNNAMEDPLUS_LUA"
|
|
3062
|
+
fi
|
|
3063
|
+
clip_lua="${clip_lua}end
|
|
3064
|
+
"
|
|
3065
|
+
fi
|
|
3066
|
+
fi
|
|
3067
|
+
|
|
3068
|
+
if [ -z "$dot_spec$clip_lua" ]; then
|
|
3069
|
+
rm -f "$NVIM_PLUGIN"
|
|
3070
|
+
return 0
|
|
3071
|
+
fi
|
|
3072
|
+
mkdir -p "$(dirname "$NVIM_PLUGIN")"
|
|
3073
|
+
{
|
|
3074
|
+
printf -- '-- %s. Remove it with: setup.sh disable neovim\n' "$NVIM_MARK"
|
|
3075
|
+
printf '%s' "$clip_lua"
|
|
3076
|
+
# Every file under lua/plugins must return a spec, even an empty one.
|
|
3077
|
+
if [ -n "$dot_spec" ]; then printf '%s\n' "$dot_spec"; else printf '\nreturn {}\n'; fi
|
|
3078
|
+
} > "$NVIM_PLUGIN"
|
|
3079
|
+
[ -n "$dot_spec" ] && say "neovim: unsaved-changes dot written to $NVIM_PLUGIN"
|
|
3080
|
+
[ -n "$clip_lua" ] && say "neovim: OSC 52 copy written to $NVIM_PLUGIN"
|
|
3081
|
+
return 0
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
apply_delta() {
|
|
3085
|
+
have delta || { say "delta not installed, skipping git pager"; return 0; }
|
|
3086
|
+
[ "$(cfg_get delta.set_git_pager true)" = "true" ] || { say "delta: git pager left alone"; return 0; }
|
|
3087
|
+
git config --global core.pager delta
|
|
3088
|
+
git config --global interactive.diffFilter "delta --color-only"
|
|
3089
|
+
git config --global delta.navigate true
|
|
3090
|
+
git config --global delta.line-numbers true
|
|
3091
|
+
git config --global merge.conflictstyle zdiff3
|
|
3092
|
+
say "delta set as git pager"
|
|
3093
|
+
}
|
|
3094
|
+
|
|
3095
|
+
apply_remote() {
|
|
3096
|
+
# A VM reached over SSH has no display and no clipboard of its own, so
|
|
3097
|
+
# anything that shells out to xclip fails. Put an xclip on PATH that speaks
|
|
3098
|
+
# OSC 52 to the terminal at the far end instead.
|
|
3099
|
+
[ "$(cfg_get remote.clipboard_shim true)" = "true" ] || {
|
|
3100
|
+
say "clipboard: xclip stand-in not installed"; return 0; }
|
|
3101
|
+
local real
|
|
3102
|
+
# Only when the machine has neither a real xclip nor a display for one to
|
|
3103
|
+
# talk to. Shadowing a working clipboard would be a downgrade. A stand-in
|
|
3104
|
+
# this script wrote before is not a real one, wherever it turns up.
|
|
3105
|
+
real="$(command -v xclip 2>/dev/null)"
|
|
3106
|
+
if [ -n "$real" ] && ! grep -q "Installed by acstack" "$real" 2>/dev/null; then
|
|
3107
|
+
say "clipboard: real xclip at $real, stand-in not installed"
|
|
3108
|
+
return 0
|
|
3109
|
+
fi
|
|
3110
|
+
if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then
|
|
3111
|
+
say "clipboard: this machine has a display, stand-in not installed"
|
|
3112
|
+
return 0
|
|
3113
|
+
fi
|
|
3114
|
+
cat > "$BIN/xclip" <<'H'
|
|
3115
|
+
#!/usr/bin/env bash
|
|
3116
|
+
# xclip, for a machine whose clipboard is at the other end of an SSH session.
|
|
3117
|
+
# acstack installs this only when no real xclip is present.
|
|
3118
|
+
#
|
|
3119
|
+
# It exists for programs that reach for xclip directly instead of offering a
|
|
3120
|
+
# setting. gh-dash copies pull request URLs through a Go library that looks
|
|
3121
|
+
# only for xclip, xsel, wl-copy and termux-clipboard-set, and gives up when
|
|
3122
|
+
# none is on PATH. This puts one there, and acstack-clip sends the text to
|
|
3123
|
+
# the terminal's own clipboard with OSC 52.
|
|
3124
|
+
#
|
|
3125
|
+
# Hands over to a real xclip if one turns up on PATH with a display to talk
|
|
3126
|
+
# to. Remove it with `setup.sh uninstall`. Installed by acstack.
|
|
3127
|
+
set -uo pipefail
|
|
3128
|
+
|
|
3129
|
+
PATH="$(cd "$(dirname "$0")" 2>/dev/null && pwd):$PATH"
|
|
3130
|
+
|
|
3131
|
+
# A real xclip, anywhere on PATH except beside this script.
|
|
3132
|
+
if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then
|
|
3133
|
+
self="$(cd "$(dirname "$0")" 2>/dev/null && pwd)"
|
|
3134
|
+
while IFS= read -r candidate; do
|
|
3135
|
+
[ "$(cd "$(dirname "$candidate")" 2>/dev/null && pwd)" = "$self" ] && continue
|
|
3136
|
+
exec "$candidate" "$@"
|
|
3137
|
+
done < <(type -ap xclip 2>/dev/null)
|
|
3138
|
+
fi
|
|
3139
|
+
|
|
3140
|
+
for arg in "$@"; do
|
|
3141
|
+
case "$arg" in
|
|
3142
|
+
-o|-out|-output) exec acstack-clip --out ;;
|
|
3143
|
+
esac
|
|
3144
|
+
done
|
|
3145
|
+
exec acstack-clip
|
|
3146
|
+
H
|
|
3147
|
+
chmod +x "$BIN/xclip"
|
|
3148
|
+
say "clipboard: xclip stand-in written, copying over OSC 52"
|
|
3149
|
+
}
|
|
3150
|
+
|
|
3151
|
+
apply_harlequin() {
|
|
3152
|
+
# uv is astral's python installer. harlequin is a python program, so there is
|
|
3153
|
+
# no release tarball to unpack: uv builds it an environment of its own and
|
|
3154
|
+
# links one command into ~/.local/bin. Nothing else here needs uv, so a
|
|
3155
|
+
# machine without it loses this component and keeps the rest.
|
|
3156
|
+
have uv || {
|
|
3157
|
+
say "uv not installed, skipping harlequin"
|
|
3158
|
+
say "install it with: curl -LsSf https://astral.sh/uv/install.sh | sh"
|
|
3159
|
+
return 0
|
|
3160
|
+
}
|
|
3161
|
+
local a adapters missing=0
|
|
3162
|
+
adapters=$(cfg_get_list harlequin.adapters "postgres snowflake")
|
|
3163
|
+
local -a with=()
|
|
3164
|
+
for a in $adapters; do with+=(--with "harlequin-$a"); done
|
|
3165
|
+
|
|
3166
|
+
# uv records what it installed in a receipt beside the environment.
|
|
3167
|
+
# `uv tool install` on an already-installed tool does nothing at all, so an
|
|
3168
|
+
# adapter added to the config would never arrive without --force. Compare the
|
|
3169
|
+
# receipt against the config and force only when they differ.
|
|
3170
|
+
local receipt="${UV_TOOL_DIR:-$HOME/.local/share/uv/tools}/harlequin/uv-receipt.toml"
|
|
3171
|
+
if have harlequin && [ -f "$receipt" ]; then
|
|
3172
|
+
for a in $adapters; do
|
|
3173
|
+
grep -q "name = \"harlequin-$a\"" "$receipt" || missing=1
|
|
3174
|
+
done
|
|
3175
|
+
[ "$missing" = "0" ] && { say "harlequin already installed"; return 0; }
|
|
3176
|
+
say "harlequin: adapters changed, reinstalling"
|
|
3177
|
+
uv tool install --force harlequin "${with[@]}" >/dev/null 2>&1 \
|
|
3178
|
+
&& say "harlequin reinstalled with: $(echo $adapters)" \
|
|
3179
|
+
|| say "harlequin reinstall failed, run: uv tool install --force harlequin"
|
|
3180
|
+
return 0
|
|
3181
|
+
fi
|
|
3182
|
+
uv tool install harlequin "${with[@]}" >/dev/null 2>&1 \
|
|
3183
|
+
&& say "harlequin installed with: $(echo $adapters)" \
|
|
3184
|
+
|| say "harlequin install failed, run: uv tool install harlequin"
|
|
3185
|
+
}
|
|
3186
|
+
|
|
3187
|
+
apply_ghdash() {
|
|
3188
|
+
have gh || { say "gh not installed, skipping dashboard"; return 0; }
|
|
3189
|
+
gh extension list 2>/dev/null | grep -q gh-dash || gh extension install dlvhdr/gh-dash >/dev/null 2>&1
|
|
3190
|
+
mkdir -p "$(dirname "$GHDASH_CFG")"
|
|
3191
|
+
ACSTACK_CFG="$CFG" py "$GHDASH_CFG" <<'PY'
|
|
3192
|
+
import os, sys, yaml
|
|
3193
|
+
src, dst = os.environ["ACSTACK_CFG"], sys.argv[1]
|
|
3194
|
+
# `or {}` on the section as well as the file. A bare "gh_dash:" with nothing
|
|
3195
|
+
# under it, which is what commenting the block out leaves behind, parses to
|
|
3196
|
+
# None, and every tw.get below then raises AttributeError. apply_ghdash died
|
|
3197
|
+
# on that with a traceback and wrote no config at all. The keybindings and
|
|
3198
|
+
# remote lookups below already guard the same way.
|
|
3199
|
+
tw = ((yaml.safe_load(open(src)) or {}).get("gh_dash") or {}) if os.path.isfile(src) else {}
|
|
3200
|
+
cfg = yaml.safe_load(open(dst)) or {} if os.path.isfile(dst) else {}
|
|
3201
|
+
if tw.get("sections"):
|
|
3202
|
+
cfg["prSections"] = [{"title": s["title"], "filters": s["filters"]} for s in tw["sections"]]
|
|
3203
|
+
if tw.get("repo_paths"):
|
|
3204
|
+
cfg["repoPaths"] = tw["repo_paths"]
|
|
3205
|
+
kb = tw.get("keybindings", {}) or {}
|
|
3206
|
+
# Every other setting reaches this script through cfg_get, which carries its
|
|
3207
|
+
# own default. This block reads the yaml itself, so a machine with no
|
|
3208
|
+
# terminal-workspace config used to land here with an empty dict: the prune
|
|
3209
|
+
# below dropped the review and agent keys, and nothing was written back. D
|
|
3210
|
+
# and S went silently dead while o, which already defaulted, kept working.
|
|
3211
|
+
# Defaults here, matching config.example.yaml. An empty string still disables.
|
|
3212
|
+
review = kb.get("review", "D")
|
|
3213
|
+
agent_review = kb.get("agent_review", "S")
|
|
3214
|
+
# "tree diff" is the old name for the review key, kept here so re-applying
|
|
3215
|
+
# over a config written before the rename drops it rather than leaving two
|
|
3216
|
+
# bindings on the same key.
|
|
3217
|
+
MANAGED = ("review (tuicr)", "tree diff", "agent review", "open in browser")
|
|
3218
|
+
prs = [k for k in (cfg.get("keybindings", {}) or {}).get("prs", [])
|
|
3219
|
+
if k.get("name") not in MANAGED]
|
|
3220
|
+
if review:
|
|
3221
|
+
prs.append({"key": review, "name": "review (tuicr)",
|
|
3222
|
+
"command": "cd {{.RepoPath}} && tuicr pr {{.PrNumber}}\n"})
|
|
3223
|
+
if agent_review:
|
|
3224
|
+
prs.append({"key": agent_review, "name": "agent review",
|
|
3225
|
+
"command": 'cd {{.RepoPath}} && claude "/code-review {{.PrNumber}}"\n'})
|
|
3226
|
+
kbs = cfg.setdefault("keybindings", {})
|
|
3227
|
+
# o built into gh-dash opens through $BROWSER, whose output it discards. That
|
|
3228
|
+
# is enough for a machine with a desktop and nothing at all for one without,
|
|
3229
|
+
# where the only way to reach a browser is to hand the terminal a link. Take
|
|
3230
|
+
# the key so acstack-open gets a terminal to draw on. GH_HOST covers
|
|
3231
|
+
# GitHub Enterprise, whose URLs are the same shape on a different host.
|
|
3232
|
+
open_key = (tw.get("remote", {}) or {}).get("open_key", "o")
|
|
3233
|
+
if open_key:
|
|
3234
|
+
host = "https://${GH_HOST:-github.com}"
|
|
3235
|
+
prs.append({"key": open_key, "name": "open in browser",
|
|
3236
|
+
"command": f'acstack-open "{host}/{{{{.RepoName}}}}/pull/{{{{.PrNumber}}}}"\n'})
|
|
3237
|
+
issues = [k for k in (cfg.get("keybindings", {}) or {}).get("issues", [])
|
|
3238
|
+
if k.get("name") not in MANAGED]
|
|
3239
|
+
issues.append({"key": open_key, "name": "open in browser",
|
|
3240
|
+
"command": f'acstack-open "{host}/{{{{.RepoName}}}}/issues/{{{{.IssueNumber}}}}"\n'})
|
|
3241
|
+
kbs["issues"] = issues
|
|
3242
|
+
kbs["prs"] = prs
|
|
3243
|
+
if tw.get("page_keys", True):
|
|
3244
|
+
uni = [k for k in kbs.get("universal", []) if k.get("builtin") not in ("pageUp", "pageDown")]
|
|
3245
|
+
uni += [{"key": "pgup", "builtin": "pageUp"}, {"key": "pgdown", "builtin": "pageDown"}]
|
|
3246
|
+
kbs["universal"] = uni
|
|
3247
|
+
yaml.safe_dump(cfg, open(dst, "w"), sort_keys=False, default_flow_style=False, width=200)
|
|
3248
|
+
print(" gh-dash config written")
|
|
3249
|
+
PY
|
|
3250
|
+
}
|
|
3251
|
+
|
|
3252
|
+
# Every edit the pi component makes to pi's own files goes through here, and
|
|
3253
|
+
# so does the record of what it changed. pi's config is JSON, which has no
|
|
3254
|
+
# comment to mark a managed region with, so ownership lives in $PI_OWNED.
|
|
3255
|
+
# pkg <identity> the configured source pi holds for a package
|
|
3256
|
+
# own|disown pkg|file <name> record or forget one thing this setup added
|
|
3257
|
+
# owns pkg|file <name> exit 0 when this setup added it
|
|
3258
|
+
# parses <file> exit 0 when the file is absent or valid JSON
|
|
3259
|
+
# merge <file> <slot> <json> claim each dotted key with a value, and release
|
|
3260
|
+
# each one set to null, putting back what it held
|
|
3261
|
+
# pin <identity> <prior> <ours> record a pin apply moved, keeping the first prior
|
|
3262
|
+
# pin-get <identity> the prior and our pin, tab-separated
|
|
3263
|
+
# pin-forget <identity> drop that record
|
|
3264
|
+
# md write|strip the delegation block in pi's context file
|
|
3265
|
+
pi_state() {
|
|
3266
|
+
ACSTACK_PI_OWNED="$PI_OWNED" ACSTACK_PI_SETTINGS="$PI_SETTINGS" \
|
|
3267
|
+
ACSTACK_PI_DIR="$PI_DIR" ACSTACK_PI_MD="$PI_MD_BEGIN|$PI_MD_END|$PI_DELEGATION" \
|
|
3268
|
+
py "$@" <<'PY'
|
|
3269
|
+
import json, os, re, sys
|
|
3270
|
+
|
|
3271
|
+
OWNED = os.environ["ACSTACK_PI_OWNED"]
|
|
3272
|
+
MISSING = object()
|
|
3273
|
+
|
|
3274
|
+
def load_owned():
|
|
3275
|
+
try:
|
|
3276
|
+
d = json.load(open(OWNED))
|
|
3277
|
+
except (OSError, ValueError):
|
|
3278
|
+
d = {}
|
|
3279
|
+
return d if isinstance(d, dict) else {}
|
|
3280
|
+
|
|
3281
|
+
def save_owned(d):
|
|
3282
|
+
d = {k: v for k, v in d.items() if v}
|
|
3283
|
+
if not d:
|
|
3284
|
+
if os.path.exists(OWNED):
|
|
3285
|
+
os.remove(OWNED)
|
|
3286
|
+
return
|
|
3287
|
+
os.makedirs(os.path.dirname(OWNED), exist_ok=True)
|
|
3288
|
+
json.dump(d, open(OWNED, "w"), indent=2)
|
|
3289
|
+
|
|
3290
|
+
def load_json(path):
|
|
3291
|
+
if not os.path.exists(path):
|
|
3292
|
+
return {}
|
|
3293
|
+
d = json.load(open(path))
|
|
3294
|
+
if not isinstance(d, dict):
|
|
3295
|
+
raise ValueError("not an object")
|
|
3296
|
+
return d
|
|
3297
|
+
|
|
3298
|
+
def identity(src):
|
|
3299
|
+
# pi names a package by its npm name or its repository without the ref, so
|
|
3300
|
+
# two pins of one repository are one package.
|
|
3301
|
+
if isinstance(src, dict):
|
|
3302
|
+
src = src.get("source", "")
|
|
3303
|
+
if src.startswith("npm:"):
|
|
3304
|
+
spec = src[4:]
|
|
3305
|
+
at = spec.rfind("@")
|
|
3306
|
+
return "npm:" + (spec[:at] if at > 0 else spec)
|
|
3307
|
+
m = re.match(r"^(?:git:|https?://|ssh://)(?:git@)?(.+?)(?:\.git)?(?:@[^/@]+)?$", src)
|
|
3308
|
+
if m:
|
|
3309
|
+
return "git:" + m.group(1).replace(":", "/")
|
|
3310
|
+
return src
|
|
3311
|
+
|
|
3312
|
+
cmd, args = sys.argv[1], sys.argv[2:]
|
|
3313
|
+
|
|
3314
|
+
if cmd == "pkg":
|
|
3315
|
+
try:
|
|
3316
|
+
pkgs = load_json(os.environ["ACSTACK_PI_SETTINGS"]).get("packages") or []
|
|
3317
|
+
except ValueError:
|
|
3318
|
+
raise SystemExit(1)
|
|
3319
|
+
for p in pkgs:
|
|
3320
|
+
if identity(p) == args[0]:
|
|
3321
|
+
print(p.get("source") if isinstance(p, dict) else p)
|
|
3322
|
+
raise SystemExit
|
|
3323
|
+
raise SystemExit(1)
|
|
3324
|
+
|
|
3325
|
+
if cmd in ("own", "disown", "owns"):
|
|
3326
|
+
kind, name = args
|
|
3327
|
+
slot = {"pkg": "packages", "file": "files"}[kind]
|
|
3328
|
+
d = load_owned()
|
|
3329
|
+
have = d.get(slot, [])
|
|
3330
|
+
if cmd == "owns":
|
|
3331
|
+
raise SystemExit(0 if name in have else 1)
|
|
3332
|
+
have = [x for x in have if x != name] + ([name] if cmd == "own" else [])
|
|
3333
|
+
d[slot] = have
|
|
3334
|
+
save_owned(d)
|
|
3335
|
+
raise SystemExit
|
|
3336
|
+
|
|
3337
|
+
if cmd in ("pin", "pin-get", "pin-forget"):
|
|
3338
|
+
d = load_owned()
|
|
3339
|
+
pins = d.setdefault("pins", {})
|
|
3340
|
+
if cmd == "pin":
|
|
3341
|
+
ident, was, ours = args
|
|
3342
|
+
pins[ident] = {"prior": pins.get(ident, {}).get("prior", was), "ours": ours}
|
|
3343
|
+
elif cmd == "pin-forget":
|
|
3344
|
+
pins.pop(args[0], None)
|
|
3345
|
+
else:
|
|
3346
|
+
rec = pins.get(args[0])
|
|
3347
|
+
if not rec:
|
|
3348
|
+
raise SystemExit(1)
|
|
3349
|
+
print("%s\t%s" % (rec["prior"], rec["ours"]))
|
|
3350
|
+
raise SystemExit
|
|
3351
|
+
save_owned(d)
|
|
3352
|
+
raise SystemExit
|
|
3353
|
+
|
|
3354
|
+
if cmd == "parses":
|
|
3355
|
+
try:
|
|
3356
|
+
load_json(args[0])
|
|
3357
|
+
except ValueError:
|
|
3358
|
+
raise SystemExit(1)
|
|
3359
|
+
raise SystemExit
|
|
3360
|
+
|
|
3361
|
+
if cmd == "merge":
|
|
3362
|
+
path, slot, want = args[0], args[1], json.loads(args[2])
|
|
3363
|
+
try:
|
|
3364
|
+
d = load_json(path)
|
|
3365
|
+
except ValueError:
|
|
3366
|
+
print(" pi: %s does not parse, so it was left alone" % path)
|
|
3367
|
+
raise SystemExit(3)
|
|
3368
|
+
owned = load_owned()
|
|
3369
|
+
mine = owned.setdefault(slot, {})
|
|
3370
|
+
# What a claimed key held before apply first changed it, so release can
|
|
3371
|
+
# put the user's value back rather than delete it.
|
|
3372
|
+
prior = owned.setdefault(slot + "_prior", {})
|
|
3373
|
+
wrote, dropped = [], []
|
|
3374
|
+
for key, value in want.items():
|
|
3375
|
+
parents, leaf = ([], key) if slot == "keybindings" else (key.split(".")[:-1], key.split(".")[-1])
|
|
3376
|
+
node = d
|
|
3377
|
+
for p in parents:
|
|
3378
|
+
if node.get(p, MISSING) is MISSING and value is not None:
|
|
3379
|
+
node[p] = {}
|
|
3380
|
+
node = node.get(p)
|
|
3381
|
+
if not isinstance(node, dict):
|
|
3382
|
+
break
|
|
3383
|
+
if not isinstance(node, dict):
|
|
3384
|
+
# A parent the user set to something other than an object, or
|
|
3385
|
+
# deleted. Writing through it would replace their value, so
|
|
3386
|
+
# concede this key. On release there is nothing left of ours.
|
|
3387
|
+
if value is not None:
|
|
3388
|
+
print(" pi: %s in %s is not an object, %s left alone" % (".".join(parents), path, key))
|
|
3389
|
+
else:
|
|
3390
|
+
mine.pop(key, None)
|
|
3391
|
+
prior.pop(key, None)
|
|
3392
|
+
continue
|
|
3393
|
+
have = node.get(leaf, MISSING)
|
|
3394
|
+
if value is None:
|
|
3395
|
+
# Released. Delete only a value this setup wrote and nobody has
|
|
3396
|
+
# changed since; anything else is the user's.
|
|
3397
|
+
if key in mine and have == mine[key]:
|
|
3398
|
+
if key in prior:
|
|
3399
|
+
node[leaf] = prior[key]
|
|
3400
|
+
else:
|
|
3401
|
+
del node[leaf]
|
|
3402
|
+
cur = d
|
|
3403
|
+
for p in parents[:-1]:
|
|
3404
|
+
cur = cur[p]
|
|
3405
|
+
if parents and not node:
|
|
3406
|
+
del cur[parents[-1]]
|
|
3407
|
+
dropped.append(key)
|
|
3408
|
+
mine.pop(key, None)
|
|
3409
|
+
prior.pop(key, None)
|
|
3410
|
+
elif have != value:
|
|
3411
|
+
if key not in mine and have is not MISSING:
|
|
3412
|
+
prior[key] = have
|
|
3413
|
+
node[leaf] = value
|
|
3414
|
+
mine[key] = value
|
|
3415
|
+
wrote.append(key)
|
|
3416
|
+
save_owned(owned)
|
|
3417
|
+
if wrote or dropped:
|
|
3418
|
+
if d or not os.path.exists(path):
|
|
3419
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
3420
|
+
with open(path, "w") as f:
|
|
3421
|
+
# pi writes its own settings unescaped, so a value in any
|
|
3422
|
+
# script round-trips as the user typed it.
|
|
3423
|
+
json.dump(d, f, indent=2, ensure_ascii=False)
|
|
3424
|
+
f.write("\n")
|
|
3425
|
+
else:
|
|
3426
|
+
# Everything in it was ours, and an empty file is no setting at all.
|
|
3427
|
+
os.remove(path)
|
|
3428
|
+
if wrote:
|
|
3429
|
+
print(" pi: set %s in %s" % (", ".join(wrote), path))
|
|
3430
|
+
if dropped:
|
|
3431
|
+
print(" pi: released %s in %s" % (", ".join(dropped), path))
|
|
3432
|
+
raise SystemExit
|
|
3433
|
+
|
|
3434
|
+
if cmd == "md":
|
|
3435
|
+
begin, end, line = os.environ["ACSTACK_PI_MD"].split("|")
|
|
3436
|
+
agent_dir = os.environ["ACSTACK_PI_DIR"]
|
|
3437
|
+
# pi reads one context file from its agent directory, the first of these
|
|
3438
|
+
# that exists. A block in any other one would never reach the model.
|
|
3439
|
+
names = ["AGENTS.override.md", "AGENTS.md", "AGENTS.MD", "CLAUDE.md", "CLAUDE.MD"]
|
|
3440
|
+
paths = [os.path.join(agent_dir, n) for n in names]
|
|
3441
|
+
target = next((p for p in paths if os.path.isfile(p)), paths[1])
|
|
3442
|
+
block = re.compile(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", re.S)
|
|
3443
|
+
# The blank line write puts before the block goes with it, so stripping
|
|
3444
|
+
# hands back the user's text exactly as it was.
|
|
3445
|
+
gap = re.compile(r"\n?" + block.pattern, re.S)
|
|
3446
|
+
state = load_owned()
|
|
3447
|
+
owned_md = state.get("md", {})
|
|
3448
|
+
for p in paths:
|
|
3449
|
+
if not os.path.isfile(p) or (args[0] == "write" and p == target):
|
|
3450
|
+
continue
|
|
3451
|
+
t = open(p).read()
|
|
3452
|
+
# write records the exact separator it added. Removing that and no
|
|
3453
|
+
# more hands back the user's file byte for byte, final newline or not.
|
|
3454
|
+
sep = owned_md.get("sep")
|
|
3455
|
+
cut = re.compile(re.escape(sep) + block.pattern, re.S) if sep is not None else gap
|
|
3456
|
+
stripped = cut.sub("", t)
|
|
3457
|
+
if stripped != t:
|
|
3458
|
+
if stripped.strip():
|
|
3459
|
+
open(p, "w").write(stripped)
|
|
3460
|
+
else:
|
|
3461
|
+
os.remove(p)
|
|
3462
|
+
print(" pi: delegation line removed from %s" % p)
|
|
3463
|
+
if args[0] == "strip":
|
|
3464
|
+
state.pop("md", None)
|
|
3465
|
+
save_owned(state)
|
|
3466
|
+
if args[0] == "write":
|
|
3467
|
+
t = open(target).read() if os.path.isfile(target) else ""
|
|
3468
|
+
mine = "%s\n%s\n%s\n" % (begin, line, end)
|
|
3469
|
+
if block.search(t):
|
|
3470
|
+
new = block.sub(lambda m: mine, t, count=1)
|
|
3471
|
+
else:
|
|
3472
|
+
sep = ("\n" if t and not t.endswith("\n") else "") + ("\n" if t else "")
|
|
3473
|
+
new = t + sep + mine
|
|
3474
|
+
state["md"] = {"sep": sep}
|
|
3475
|
+
save_owned(state)
|
|
3476
|
+
if new != t:
|
|
3477
|
+
os.makedirs(agent_dir, exist_ok=True)
|
|
3478
|
+
open(target, "w").write(new)
|
|
3479
|
+
print(" pi: delegation line written to %s" % target)
|
|
3480
|
+
raise SystemExit
|
|
3481
|
+
PY
|
|
3482
|
+
}
|
|
3483
|
+
|
|
3484
|
+
# The four personal settings, from the config, as the JSON merge reads them.
|
|
3485
|
+
# An empty value releases the key rather than writing an empty one.
|
|
3486
|
+
pi_settings_wanted() {
|
|
3487
|
+
ACSTACK_CFG="$CFG" py <<'PY'
|
|
3488
|
+
import json, os, sys
|
|
3489
|
+
import yaml
|
|
3490
|
+
p = os.environ["ACSTACK_CFG"]
|
|
3491
|
+
c = (yaml.safe_load(open(p)) or {}) if os.path.isfile(p) else {}
|
|
3492
|
+
s = ((c.get("pi") or {}).get("settings") or {})
|
|
3493
|
+
# config key -> (pi's dotted key, the default config.example.yaml ships, the
|
|
3494
|
+
# values pi accepts). YAML reads a bare off as false, so mermaid maps it back.
|
|
3495
|
+
KEYS = {"hide_thinking_block": ("hideThinkingBlock", True, (True, False)),
|
|
3496
|
+
"mermaid": ("markdown.mermaid", "streaming", ("off", "final", "streaming")),
|
|
3497
|
+
"install_telemetry": ("enableInstallTelemetry", False, (True, False)),
|
|
3498
|
+
"theme": ("theme", "system", None)}
|
|
3499
|
+
out = {}
|
|
3500
|
+
for k, (key, default, allowed) in KEYS.items():
|
|
3501
|
+
v = s.get(k, default)
|
|
3502
|
+
if v in ("", None):
|
|
3503
|
+
out[key] = None
|
|
3504
|
+
continue
|
|
3505
|
+
if k == "mermaid" and v is False:
|
|
3506
|
+
v = "off"
|
|
3507
|
+
ok = (v in allowed and type(v) is type(allowed[0])) if allowed else isinstance(v, str)
|
|
3508
|
+
if not ok:
|
|
3509
|
+
# Left out entirely, so pi keeps whatever it holds now.
|
|
3510
|
+
print(" pi: settings.%s cannot be %r, left alone" % (k, v), file=sys.stderr)
|
|
3511
|
+
continue
|
|
3512
|
+
out[key] = v
|
|
3513
|
+
print(json.dumps(out))
|
|
3514
|
+
PY
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
# npm installs pi into its global bin, which a fresh shell may not have on
|
|
3518
|
+
# PATH. Asking npm costs a process, so only when pi is not already found.
|
|
3519
|
+
find_pi() {
|
|
3520
|
+
have pi || ! have npm || PATH="$PATH:$(npm prefix -g 2>/dev/null)/bin"
|
|
3521
|
+
}
|
|
3522
|
+
|
|
3523
|
+
|
|
3524
|
+
# Pi's bundled TUI does not always receive Windows Terminal paste as plain
|
|
3525
|
+
# text. Through Herdr and ConPTY it can arrive as Win32 input records such as
|
|
3526
|
+
# ESC[13;28;13;1;0;1_. Patch the installed bundle after install or update.
|
|
3527
|
+
patch_pi_win32_paste() {
|
|
3528
|
+
have pi || return 0
|
|
3529
|
+
local pi_bin pi_real root chunks
|
|
3530
|
+
pi_bin=$(command -v pi) || return 0
|
|
3531
|
+
pi_real=$(readlink -f "$pi_bin" 2>/dev/null || printf '%s' "$pi_bin")
|
|
3532
|
+
case "$pi_real" in
|
|
3533
|
+
*/dist/bundle/cli.js) root=${pi_real%/dist/bundle/cli.js} ;;
|
|
3534
|
+
*)
|
|
3535
|
+
root="${pi_real%/bin/pi}/lib/node_modules/@earendil-works/pi-coding-agent"
|
|
3536
|
+
if [ ! -d "$root/dist/bundle/chunks" ]; then
|
|
3537
|
+
have npm || { say "pi: npm not on PATH, so Win32 paste patch was skipped"; return 0; }
|
|
3538
|
+
root="$(npm root -g 2>/dev/null)/@earendil-works/pi-coding-agent"
|
|
3539
|
+
fi
|
|
3540
|
+
;;
|
|
3541
|
+
esac
|
|
3542
|
+
chunks="$root/dist/bundle/chunks"
|
|
3543
|
+
[ -d "$chunks" ] || { say "pi: bundle chunks not found, so Win32 paste patch was skipped"; return 0; }
|
|
3544
|
+
SPECHUB_PI_CHUNKS="$chunks" py <<'PI_PATCH_PY'
|
|
3545
|
+
import os, pathlib
|
|
3546
|
+
chunks = pathlib.Path(os.environ["SPECHUB_PI_CHUNKS"])
|
|
3547
|
+
target = None
|
|
3548
|
+
for p in chunks.glob("*.js"):
|
|
3549
|
+
try:
|
|
3550
|
+
s = p.read_text()
|
|
3551
|
+
except (OSError, UnicodeDecodeError):
|
|
3552
|
+
continue
|
|
3553
|
+
if "function decodePrintableKey(data)" in s and "handlePaste(pastedText)" in s:
|
|
3554
|
+
target = p
|
|
3555
|
+
break
|
|
3556
|
+
if target is None:
|
|
3557
|
+
print(" pi: TUI bundle not found, so Win32 paste patch was skipped")
|
|
3558
|
+
raise SystemExit
|
|
3559
|
+
s = target.read_text()
|
|
3560
|
+
changed = False
|
|
3561
|
+
repls = [
|
|
3562
|
+
('function isKeyRelease(data){return data.includes("\\x1B[200~")?!1:!!(data.includes(":3u")||data.includes(":3~")||data.includes(":3A")||data.includes(":3B")||data.includes(":3C")||data.includes(":3D")||data.includes(":3H")||data.includes(":3F"))}',
|
|
3563
|
+
'function isKeyRelease(data){let win32=parseWin32InputRecord(data);if(win32)return!win32.keyDown;return data.includes("\\x1B[200~")?!1:!!(data.includes(":3u")||data.includes(":3~")||data.includes(":3A")||data.includes(":3B")||data.includes(":3C")||data.includes(":3D")||data.includes(":3H")||data.includes(":3F"))}'),
|
|
3564
|
+
('function parseModifyOtherKeysSequence(data){let match=data.match(/^\\x1b\\[27;(\\d+);(\\d+)~$/);if(!match)return null;let modValue=parseInt(match[1],10);return{codepoint:parseInt(match[2],10),modifier:modValue-1}}',
|
|
3565
|
+
'function parseModifyOtherKeysSequence(data){let match=data.match(/^\\x1b\\[27;(\\d+);(\\d+)~$/);if(!match)return null;let modValue=parseInt(match[1],10);return{codepoint:parseInt(match[2],10),modifier:modValue-1}}function parseWin32InputRecord(data){let match=data.match(/^\\x1b\\[(\\d+);(\\d+);(-?\\d+);([01]);(\\d+);(\\d+)_$/);if(!match)return null;let virtualKey=Number.parseInt(match[1],10),scanCode=Number.parseInt(match[2],10),codepoint=Number.parseInt(match[3],10),keyDown=match[4]==="1",controlState=Number.parseInt(match[5],10),modifier=0;if((controlState&16)!==0)modifier|=MODIFIERS.shift;if((controlState&(1|2))!==0)modifier|=MODIFIERS.alt;if((controlState&(4|8))!==0)modifier|=MODIFIERS.ctrl;if((controlState&32)!==0)modifier|=128;if((controlState&128)!==0)modifier|=64;return{virtualKey,scanCode,codepoint,keyDown,modifier}}'),
|
|
3566
|
+
('function parseKey(data){let kitty=parseKittySequence(data);',
|
|
3567
|
+
'function parseKey(data){let win32=parseWin32InputRecord(data);if(win32){_lastEventType=win32.keyDown?"press":"release";if(!win32.keyDown)return;let codepoint=win32.codepoint;if(win32.virtualKey===46)codepoint=FUNCTIONAL_CODEPOINTS.delete;else if(win32.virtualKey===45)codepoint=FUNCTIONAL_CODEPOINTS.insert;else if(win32.virtualKey===33)codepoint=FUNCTIONAL_CODEPOINTS.pageUp;else if(win32.virtualKey===34)codepoint=FUNCTIONAL_CODEPOINTS.pageDown;else if(win32.virtualKey===35)codepoint=FUNCTIONAL_CODEPOINTS.end;else if(win32.virtualKey===36)codepoint=FUNCTIONAL_CODEPOINTS.home;else if(win32.virtualKey===37)codepoint=ARROW_CODEPOINTS.left;else if(win32.virtualKey===38)codepoint=ARROW_CODEPOINTS.up;else if(win32.virtualKey===39)codepoint=ARROW_CODEPOINTS.right;else if(win32.virtualKey===40)codepoint=ARROW_CODEPOINTS.down;else if(win32.virtualKey===8)codepoint=CODEPOINTS.backspace;else if(win32.virtualKey===9)codepoint=CODEPOINTS.tab;else if(win32.virtualKey===13)codepoint=CODEPOINTS.enter;else if(win32.virtualKey===27)codepoint=CODEPOINTS.escape;return formatParsedKey(codepoint,win32.modifier)}let kitty=parseKittySequence(data);'),
|
|
3568
|
+
('function decodeModifyOtherKeysPrintable(data){let parsed=parseModifyOtherKeysSequence(data);if(!(!parsed||(parsed.modifier&~LOCK_MASK&~MODIFIERS.shift)!==0)&&!(!Number.isFinite(parsed.codepoint)||parsed.codepoint<32))try{return String.fromCodePoint(parsed.codepoint)}catch{return}}function decodePrintableKey(data){return decodeKittyPrintable(data)??decodeModifyOtherKeysPrintable(data)}',
|
|
3569
|
+
'function decodeModifyOtherKeysPrintable(data){let parsed=parseModifyOtherKeysSequence(data);if(!(!parsed||(parsed.modifier&~LOCK_MASK&~MODIFIERS.shift)!==0)&&!(!Number.isFinite(parsed.codepoint)||parsed.codepoint<32))try{return String.fromCodePoint(parsed.codepoint)}catch{return}}function decodeWin32Printable(data){let parsed=parseWin32InputRecord(data);if(!parsed||!parsed.keyDown)return;let modifier=parsed.modifier&~LOCK_MASK;if((modifier&(MODIFIERS.ctrl|MODIFIERS.alt|MODIFIERS.super))!==0)return;if(!Number.isFinite(parsed.codepoint)||parsed.codepoint<32)return;try{return String.fromCodePoint(parsed.codepoint)}catch{return}}function decodePrintableKey(data){return decodeKittyPrintable(data)??decodeModifyOtherKeysPrintable(data)??decodeWin32Printable(data)}'),
|
|
3570
|
+
('let decodedText=pastedText.replace(/\\x1b\\[(\\d+);5u/g,(match,code)=>{let cp=Number(code);return cp>=97&&cp<=122?String.fromCharCode(cp-96):cp>=65&&cp<=90?String.fromCharCode(cp-64):match}),filteredText=this.normalizeText(decodedText).split("").filter(char=>char===`\n`||char.charCodeAt(0)>=32).join("");',
|
|
3571
|
+
'let decodedText=pastedText.replace(/\\x1b\\[(\\d+);(\\d+);(-?\\d+);([01]);(\\d+);(\\d+)_/g,(match,vk,sc,uc,kd)=>{if(kd!=="1")return "";let cp=Number(uc),key=Number(vk);if(key===13||cp===13)return `\n`;if(cp>=32)try{return String.fromCodePoint(cp)}catch{return ""}return ""}).replace(/\\x1b\\[(\\d+);5u/g,(match,code)=>{let cp=Number(code);return cp>=97&&cp<=122?String.fromCharCode(cp-96):cp>=65&&cp<=90?String.fromCharCode(cp-64):match}),filteredText=this.normalizeText(decodedText).split("").filter(char=>char===`\n`||char.charCodeAt(0)>=32).join("");')]
|
|
3572
|
+
missing = []
|
|
3573
|
+
for old, new in repls:
|
|
3574
|
+
if new in s:
|
|
3575
|
+
continue
|
|
3576
|
+
if old not in s:
|
|
3577
|
+
missing.append(old[:80])
|
|
3578
|
+
continue
|
|
3579
|
+
s = s.replace(old, new, 1)
|
|
3580
|
+
changed = True
|
|
3581
|
+
if missing:
|
|
3582
|
+
print(" pi: Win32 paste patch did not match this Pi bundle")
|
|
3583
|
+
raise SystemExit
|
|
3584
|
+
if changed:
|
|
3585
|
+
try:
|
|
3586
|
+
target.write_text(s)
|
|
3587
|
+
except OSError as exc:
|
|
3588
|
+
print(f" pi: could not patch {target} ({exc})")
|
|
3589
|
+
raise SystemExit
|
|
3590
|
+
print(" pi: Win32 paste patch applied")
|
|
3591
|
+
else:
|
|
3592
|
+
print(" pi: Win32 paste patch already applied")
|
|
3593
|
+
PI_PATCH_PY
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3596
|
+
# The ACStack version running this script, which is the one pi should pin.
|
|
3597
|
+
acstack_version() {
|
|
3598
|
+
json_field "$(dirname "$0")/../../.claude-plugin/plugin.json" version
|
|
3599
|
+
}
|
|
3600
|
+
|
|
3601
|
+
# Installs one pi package, or moves its pin. Ownership is taken only when the
|
|
3602
|
+
# package was absent: one the user installed stays theirs, pin and all.
|
|
3603
|
+
pi_add() { # pi_add <source> <identity>
|
|
3604
|
+
local cur
|
|
3605
|
+
cur=$(pi_state pkg "$2")
|
|
3606
|
+
if [ -n "$cur" ]; then
|
|
3607
|
+
# Unpinned, presence is enough: a pin the user chose stays.
|
|
3608
|
+
case "$1" in "$2"@*) ;; *) say "pi: $2 already installed"; return 0 ;; esac
|
|
3609
|
+
[ "$cur" = "$1" ] && { say "pi: $1 already installed"; return 0; }
|
|
3610
|
+
fi
|
|
3611
|
+
if ! pi install "$1" >/dev/null 2>&1; then
|
|
3612
|
+
say "pi: install of $1 failed, run: pi install $1"
|
|
3613
|
+
return 1
|
|
3614
|
+
fi
|
|
3615
|
+
if [ -n "$cur" ]; then
|
|
3616
|
+
pi_state pin "$2" "$cur" "$1"
|
|
3617
|
+
say "pi: $cur moved to $1"
|
|
3618
|
+
else
|
|
3619
|
+
pi_state own pkg "$2"
|
|
3620
|
+
say "pi: $1 installed"
|
|
3621
|
+
fi
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
# Puts back a pin apply moved, while the pin is still the one apply set.
|
|
3625
|
+
pi_unpin() { # pi_unpin <identity>
|
|
3626
|
+
local rec prior ours cur
|
|
3627
|
+
rec=$(pi_state pin-get "$1") || return 0
|
|
3628
|
+
prior="${rec%%$'\t'*}" ours="${rec#*$'\t'}"
|
|
3629
|
+
cur=$(pi_state pkg "$1")
|
|
3630
|
+
if [ "$cur" = "$ours" ]; then
|
|
3631
|
+
have pi || { say "pi: not on PATH, so $cur stays pinned"; return 0; }
|
|
3632
|
+
pi install "$prior" >/dev/null 2>&1 \
|
|
3633
|
+
|| { say "pi: could not put back $prior, run: pi install $prior"; return 0; }
|
|
3634
|
+
say "pi: $cur put back to $prior"
|
|
3635
|
+
fi
|
|
3636
|
+
pi_state pin-forget "$1"
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
# Removes a package this setup installed. One the user installed is left.
|
|
3640
|
+
pi_drop() { # pi_drop <identity>
|
|
3641
|
+
local cur
|
|
3642
|
+
pi_state owns pkg "$1" || return 0
|
|
3643
|
+
cur=$(pi_state pkg "$1")
|
|
3644
|
+
if [ -n "$cur" ]; then
|
|
3645
|
+
have pi || { say "pi: not on PATH, so $cur stays installed"; return 0; }
|
|
3646
|
+
pi remove "$cur" >/dev/null 2>&1 || { say "pi: remove of $cur failed, run: pi remove $cur"; return 0; }
|
|
3647
|
+
say "pi: $cur removed"
|
|
3648
|
+
fi
|
|
3649
|
+
pi_state disown pkg "$1"
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
# A package pi holds at a fixed version, such as npm:pi-subagents@1.0.0.
|
|
3653
|
+
pi_npm_pin() { # pi_npm_pin <name>
|
|
3654
|
+
local src
|
|
3655
|
+
version_table | awk -F'\t' -v n="$1" '$1==n && $2=="pi" && $3=="npm"{found=1} END{exit !found}' \
|
|
3656
|
+
|| return 1
|
|
3657
|
+
src=$(pi_state pkg "npm:$1") || return 1
|
|
3658
|
+
[ "$src" != "npm:$1" ]
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3661
|
+
# The pin apply would move to, or nothing when the installed one is current
|
|
3662
|
+
# or newer. A newer pin is one the user chose, so it is never moved back.
|
|
3663
|
+
pi_acstack_behind() {
|
|
3664
|
+
local cur ver pin
|
|
3665
|
+
ver=$(acstack_version); [ -n "$ver" ] || return 0
|
|
3666
|
+
cur=$(pi_state pkg "$PI_ACSTACK") || return 0
|
|
3667
|
+
case "$cur" in "$PI_ACSTACK"@*) pin="${cur##*@}" ;; *) return 0 ;; esac
|
|
3668
|
+
[ "$pin" = "$ver" ] && return 0
|
|
3669
|
+
[ "$(printf '%s\n%s\n' "$pin" "$ver" | sort -V | tail -1)" = "$ver" ] || return 0
|
|
3670
|
+
printf '%s\t%s' "$pin" "$ver"
|
|
3671
|
+
}
|
|
3672
|
+
|
|
3673
|
+
bili_bin() {
|
|
3674
|
+
if [ -x "$PI_DIR/npm/node_modules/.bin/bili" ]; then
|
|
3675
|
+
printf '%s' "$PI_DIR/npm/node_modules/.bin/bili"
|
|
3676
|
+
elif have bili; then
|
|
3677
|
+
command -v bili
|
|
3678
|
+
else
|
|
3679
|
+
return 1
|
|
3680
|
+
fi
|
|
3681
|
+
}
|
|
3682
|
+
|
|
3683
|
+
bili_status() { # bili_status <bin> <client> -> installed|absent|unknown
|
|
3684
|
+
local line
|
|
3685
|
+
line=$("$1" plugin list 2>/dev/null | awk -v c="$2" '$1==c{print; exit}') || true
|
|
3686
|
+
[ -n "$line" ] || { echo unknown; return 0; }
|
|
3687
|
+
case "$line" in *"not installed"*) echo absent ;; *) echo installed ;; esac
|
|
3688
|
+
}
|
|
3689
|
+
|
|
3690
|
+
bili_client() { # bili_client install|remove <client>
|
|
3691
|
+
local op="$1" client="$2" bin marker status
|
|
3692
|
+
marker="bili:$client"
|
|
3693
|
+
if [ "$op" = "install" ]; then
|
|
3694
|
+
[ "$client" = "pi" ] || have "$client" || return 0
|
|
3695
|
+
pi_state owns file "$marker" && return 0
|
|
3696
|
+
if bin=$(bili_bin); then
|
|
3697
|
+
status=$(bili_status "$bin" "$client")
|
|
3698
|
+
if [ "$status" = "installed" ]; then
|
|
3699
|
+
say "pi: billion-context already enabled for $client"
|
|
3700
|
+
return 0
|
|
3701
|
+
fi
|
|
3702
|
+
if "$bin" plugin install "$client" >/dev/null 2>&1; then
|
|
3703
|
+
pi_state own file "$marker"
|
|
3704
|
+
say "pi: billion-context enabled for $client"
|
|
3705
|
+
else
|
|
3706
|
+
say "pi: billion-context $client setup failed, run: bili plugin install $client"
|
|
3707
|
+
fi
|
|
3708
|
+
else
|
|
3709
|
+
say "pi: bili command not found, so billion-context $client setup was skipped"
|
|
3710
|
+
fi
|
|
3711
|
+
elif pi_state owns file "$marker"; then
|
|
3712
|
+
if bin=$(bili_bin); then
|
|
3713
|
+
if "$bin" plugin remove "$client" >/dev/null 2>&1; then
|
|
3714
|
+
pi_state disown file "$marker"
|
|
3715
|
+
say "pi: billion-context disabled for $client"
|
|
3716
|
+
else
|
|
3717
|
+
say "pi: billion-context $client removal failed, run: bili plugin remove $client"
|
|
3718
|
+
fi
|
|
3719
|
+
else
|
|
3720
|
+
say "pi: bili command not found, so billion-context $client setup was left installed"
|
|
3721
|
+
fi
|
|
3722
|
+
fi
|
|
3723
|
+
}
|
|
3724
|
+
|
|
3725
|
+
apply_pi() {
|
|
3726
|
+
find_pi
|
|
3727
|
+
if ! have pi; then
|
|
3728
|
+
have npm || {
|
|
3729
|
+
say "npm not installed, skipping pi"
|
|
3730
|
+
say "install Node.js, then run apply again"
|
|
3731
|
+
return 0
|
|
3732
|
+
}
|
|
3733
|
+
npm install -g @earendil-works/pi-coding-agent >/dev/null 2>&1 \
|
|
3734
|
+
|| { say "pi install failed, run: npm install -g @earendil-works/pi-coding-agent"; return 0; }
|
|
3735
|
+
have pi || { say "pi installed, but npm's global bin is not on PATH"; return 0; }
|
|
3736
|
+
say "pi installed"
|
|
3737
|
+
fi
|
|
3738
|
+
# pi rewrites settings.json on every install. Handing it a file it cannot
|
|
3739
|
+
# parse risks the user's settings, so concede the whole component instead.
|
|
3740
|
+
if ! pi_state parses "$PI_SETTINGS"; then
|
|
3741
|
+
say "pi: $PI_SETTINGS does not parse, so nothing was installed into pi"
|
|
3742
|
+
say "fix that file, then run apply again"
|
|
3743
|
+
return 0
|
|
3744
|
+
fi
|
|
3745
|
+
|
|
3746
|
+
patch_pi_win32_paste
|
|
3747
|
+
|
|
3748
|
+
# ACStack cannot launch a subagent on pi without pi-subagents.
|
|
3749
|
+
pi_add npm:pi-subagents npm:pi-subagents
|
|
3750
|
+
if [ "$(cfg_get pi.todo true)" = "true" ]; then
|
|
3751
|
+
pi_add npm:@juicesharp/rpiv-todo npm:@juicesharp/rpiv-todo
|
|
3752
|
+
else
|
|
3753
|
+
pi_drop npm:@juicesharp/rpiv-todo
|
|
3754
|
+
fi
|
|
3755
|
+
if [ "$(cfg_get pi.plugin_manager true)" = "true" ]; then
|
|
3756
|
+
pi_add npm:pi-plugin-manager npm:pi-plugin-manager
|
|
3757
|
+
else
|
|
3758
|
+
pi_drop npm:pi-plugin-manager
|
|
3759
|
+
fi
|
|
3760
|
+
# bili's proxy dies with the session that started it, cutting off every
|
|
3761
|
+
# other Claude Code or Codex session. Keep it in pi; undo older installs.
|
|
3762
|
+
bili_client remove codex
|
|
3763
|
+
bili_client remove claude
|
|
3764
|
+
if [ "$(cfg_get pi.billion_context true)" = "true" ]; then
|
|
3765
|
+
pi_add "$PI_BILLION" "$PI_BILLION"
|
|
3766
|
+
bili_client install pi
|
|
3767
|
+
else
|
|
3768
|
+
bili_client remove pi
|
|
3769
|
+
pi_drop "$PI_BILLION"
|
|
3770
|
+
fi
|
|
3771
|
+
if [ "$(cfg_get pi.question_tool true)" = "true" ]; then
|
|
3772
|
+
pi_add npm:@juicesharp/rpiv-ask-user-question npm:@juicesharp/rpiv-ask-user-question
|
|
3773
|
+
else
|
|
3774
|
+
pi_drop npm:@juicesharp/rpiv-ask-user-question
|
|
3775
|
+
fi
|
|
3776
|
+
if [ "$(cfg_get pi.web_access true)" = "true" ]; then
|
|
3777
|
+
pi_add npm:pi-web-access npm:pi-web-access
|
|
3778
|
+
else
|
|
3779
|
+
pi_drop npm:pi-web-access
|
|
3780
|
+
fi
|
|
3781
|
+
if [ "$(cfg_get pi.lens false)" = "true" ]; then
|
|
3782
|
+
pi_add npm:pi-lens npm:pi-lens
|
|
3783
|
+
else
|
|
3784
|
+
pi_drop npm:pi-lens
|
|
3785
|
+
fi
|
|
3786
|
+
if [ "$(cfg_get pi.slash_anywhere true)" = "true" ]; then
|
|
3787
|
+
pi_add "$PI_SLASH_ANYWHERE" "$PI_SLASH_ANYWHERE"
|
|
3788
|
+
else
|
|
3789
|
+
pi_drop "$PI_SLASH_ANYWHERE"
|
|
3790
|
+
fi
|
|
3791
|
+
local ver cur; ver=$(acstack_version)
|
|
3792
|
+
if [ -n "$ver" ]; then
|
|
3793
|
+
if cur=$(pi_state pkg "$PI_ACSTACK") && [ -z "$(pi_acstack_behind)" ]; then
|
|
3794
|
+
say "pi: $cur already installed"
|
|
3795
|
+
else
|
|
3796
|
+
pi_add "$PI_ACSTACK@$ver" "$PI_ACSTACK"
|
|
3797
|
+
fi
|
|
3798
|
+
else
|
|
3799
|
+
say "pi: no plugin version beside this script, so ACStack was not installed into pi"
|
|
3800
|
+
fi
|
|
3801
|
+
if [ "$(cfg_get pi.compact_tools true)" = "true" ]; then
|
|
3802
|
+
pi_add npm:pi-compact-tools npm:pi-compact-tools
|
|
3803
|
+
else
|
|
3804
|
+
pi_drop npm:pi-compact-tools
|
|
3805
|
+
fi
|
|
3806
|
+
|
|
3807
|
+
# agentic-retro writes its own extension file. This setup only asks it to,
|
|
3808
|
+
# and owns the file only when it was not there before.
|
|
3809
|
+
if [ "$(cfg_get pi.agentic_retro true)" = "true" ]; then
|
|
3810
|
+
if have agentic-retro; then
|
|
3811
|
+
local fresh=0; [ -e "$PI_RETRO_EXT" ] || fresh=1
|
|
3812
|
+
if agentic-retro install-pi >/dev/null 2>&1; then
|
|
3813
|
+
[ "$fresh" = "1" ] && pi_state own file "$PI_RETRO_EXT"
|
|
3814
|
+
say "pi: agentic-retro extension written"
|
|
3815
|
+
else
|
|
3816
|
+
say "pi: agentic-retro install-pi failed, run it yourself"
|
|
3817
|
+
fi
|
|
3818
|
+
else
|
|
3819
|
+
say "pi: agentic-retro not on PATH, so its pi extension was skipped"
|
|
3820
|
+
say "install the agentic-retro plugin first, then run apply again"
|
|
3821
|
+
fi
|
|
3822
|
+
elif pi_state owns file "$PI_RETRO_EXT"; then
|
|
3823
|
+
rm -f "$PI_RETRO_EXT"
|
|
3824
|
+
pi_state disown file "$PI_RETRO_EXT"
|
|
3825
|
+
say "pi: agentic-retro extension removed"
|
|
3826
|
+
fi
|
|
3827
|
+
|
|
3828
|
+
pi_state merge "$PI_SETTINGS" settings "$(pi_settings_wanted)"
|
|
3829
|
+
pi_state merge "$PI_KEYBINDINGS" keybindings '{"app.message.dequeue": "ctrl+enter"}'
|
|
3830
|
+
pi_state merge "$PI_SUBAGENT_CFG" subagent '{"toolActivation": "eager"}'
|
|
3831
|
+
pi_state md write
|
|
3832
|
+
patch_pi_win32_paste
|
|
3833
|
+
|
|
3834
|
+
# Logging in is interactive and stores a credential, so it stays the user's.
|
|
3835
|
+
[ -f "$PI_DIR/auth.json" ] || say "pi: not logged in yet. Run pi, then /login"
|
|
3836
|
+
return 0
|
|
3837
|
+
}
|
|
3838
|
+
|
|
3839
|
+
# disable pi and uninstall both come here. It undoes what apply_pi recorded
|
|
3840
|
+
# and leaves the pi binary, its logins and its sessions alone.
|
|
3841
|
+
remove_pi() {
|
|
3842
|
+
find_pi
|
|
3843
|
+
if pi_state parses "$PI_SETTINGS"; then
|
|
3844
|
+
pi_unpin "$PI_ACSTACK"
|
|
3845
|
+
pi_drop "$PI_ACSTACK"
|
|
3846
|
+
bili_client remove codex
|
|
3847
|
+
bili_client remove claude
|
|
3848
|
+
bili_client remove pi
|
|
3849
|
+
pi_drop "$PI_BILLION"
|
|
3850
|
+
pi_drop npm:pi-plugin-manager
|
|
3851
|
+
pi_drop npm:@juicesharp/rpiv-todo
|
|
3852
|
+
pi_drop npm:pi-compact-tools
|
|
3853
|
+
pi_drop npm:@juicesharp/rpiv-ask-user-question
|
|
3854
|
+
pi_drop npm:pi-web-access
|
|
3855
|
+
pi_drop npm:pi-lens
|
|
3856
|
+
pi_drop "$PI_SLASH_ANYWHERE"
|
|
3857
|
+
pi_drop npm:pi-subagents
|
|
3858
|
+
else
|
|
3859
|
+
say "pi: $PI_SETTINGS does not parse, so its packages were left installed"
|
|
3860
|
+
fi
|
|
3861
|
+
if pi_state owns file "$PI_RETRO_EXT"; then
|
|
3862
|
+
rm -f "$PI_RETRO_EXT"
|
|
3863
|
+
pi_state disown file "$PI_RETRO_EXT"
|
|
3864
|
+
say "pi: agentic-retro extension removed"
|
|
3865
|
+
fi
|
|
3866
|
+
pi_state merge "$PI_SETTINGS" settings \
|
|
3867
|
+
'{"hideThinkingBlock": null, "markdown.mermaid": null, "enableInstallTelemetry": null, "theme": null}'
|
|
3868
|
+
pi_state merge "$PI_KEYBINDINGS" keybindings '{"app.message.dequeue": null}'
|
|
3869
|
+
pi_state merge "$PI_SUBAGENT_CFG" subagent '{"toolActivation": null}'
|
|
3870
|
+
pi_state md strip
|
|
3871
|
+
return 0
|
|
3872
|
+
}
|
|
3873
|
+
|
|
3874
|
+
# Every tool with a version worth comparing.
|
|
3875
|
+
# name | component key | source | reference | release asset match
|
|
3876
|
+
# mermaid-ascii is deliberately absent: it has no version flag at all, so
|
|
3877
|
+
# nothing here can read what is installed. `upgrade mermaid-ascii` still works,
|
|
3878
|
+
# because upgrade_one carries an arm of its own for it. A tool that is in
|
|
3879
|
+
# neither place is one upgrade cannot reach, whatever the docs promise.
|
|
3880
|
+
version_table() {
|
|
3881
|
+
cat <<'T'
|
|
3882
|
+
delta delta github dandavison/delta x86_64-unknown-linux-gnu
|
|
3883
|
+
diffnav diffnav github dlvhdr/diffnav Linux_x86_64
|
|
3884
|
+
fzf diffnav github junegunn/fzf linux_amd64
|
|
3885
|
+
lazygit lazygit github jesseduffield/lazygit linux_x86_64
|
|
3886
|
+
tuicr tuicr github agavra/tuicr x86_64-unknown-linux-gnu
|
|
3887
|
+
yazi yazi github sxyazi/yazi x86_64-unknown-linux-gnu
|
|
3888
|
+
glow markdown github charmbracelet/glow Linux_x86_64
|
|
3889
|
+
herdr herdr herdr - -
|
|
3890
|
+
gh-dash gh_dash ghext dlvhdr/gh-dash -
|
|
3891
|
+
harlequin harlequin pypi harlequin -
|
|
3892
|
+
pi pi npm @earendil-works/pi-coding-agent -
|
|
3893
|
+
pi-subagents pi npm pi-subagents -
|
|
3894
|
+
billion-context pi npm billion-context -
|
|
3895
|
+
@juicesharp/rpiv-todo pi npm @juicesharp/rpiv-todo -
|
|
3896
|
+
pi-plugin-manager pi npm pi-plugin-manager -
|
|
3897
|
+
@juicesharp/rpiv-ask-user-question pi npm @juicesharp/rpiv-ask-user-question -
|
|
3898
|
+
pi-web-access pi npm pi-web-access -
|
|
3899
|
+
pi-lens pi npm pi-lens -
|
|
3900
|
+
pi-slash-anywhere pi npm pi-slash-anywhere -
|
|
3901
|
+
pi-compact-tools pi npm pi-compact-tools -
|
|
3902
|
+
T
|
|
3903
|
+
}
|
|
3904
|
+
|
|
3905
|
+
installed_version() { # installed_version <name> -> a bare version, or nothing
|
|
3906
|
+
local v=""
|
|
3907
|
+
case "$1" in
|
|
3908
|
+
# delta 0.19.2
|
|
3909
|
+
delta) v=$(delta --version 2>/dev/null | awk '{print $2}') ;;
|
|
3910
|
+
# an ASCII banner, then: version v0.12.0
|
|
3911
|
+
diffnav) v=$(diffnav --version 2>/dev/null | sed -n 's/^version //p') ;;
|
|
3912
|
+
# 0.74.3 (15f64c49)
|
|
3913
|
+
fzf) v=$(fzf --version 2>/dev/null | awk '{print $1}') ;;
|
|
3914
|
+
# commit=..., version=0.64.1, os=linux, arch=amd64, git version=2.43.0
|
|
3915
|
+
# Split on the commas first: the line ends in git's own version, and one
|
|
3916
|
+
# regex over the whole line reads that one instead of lazygit's.
|
|
3917
|
+
lazygit) v=$(lazygit --version 2>/dev/null | tr ',' '\n' | sed -n 's/^ *version=//p') ;;
|
|
3918
|
+
# glow version 3.0.0 (add5e7a)
|
|
3919
|
+
glow) v=$(glow --version 2>/dev/null | awk '{print $3}') ;;
|
|
3920
|
+
# tuicr 0.23.0
|
|
3921
|
+
tuicr) v=$(tuicr --version 2>/dev/null | awk '{print $2}') ;;
|
|
3922
|
+
# a block of lines, one of them: " Version: 26.8.15 (1f3588d 2026-08-15)"
|
|
3923
|
+
yazi) v=$(yazi --version 2>/dev/null | sed -n 's/^ *Version: \([^ ]*\).*/\1/p') ;;
|
|
3924
|
+
# herdr 0.8.2
|
|
3925
|
+
herdr) v=$(herdr --version 2>/dev/null | awk '{print $2}') ;;
|
|
3926
|
+
# gh dash<TAB>dlvhdr/gh-dash<TAB>v4.25.2
|
|
3927
|
+
gh-dash) v=$(gh extension list 2>/dev/null | awk -F'\t' '$2=="dlvhdr/gh-dash"{print $3}') ;;
|
|
3928
|
+
# harlequin v2.13.0, then an indented line per command it installed
|
|
3929
|
+
harlequin) v=$(uv tool list 2>/dev/null | awk '$1=="harlequin"{print $2}') ;;
|
|
3930
|
+
# 1.1.0
|
|
3931
|
+
pi) find_pi; v=$(pi --version 2>/dev/null | awk '{print $NF}') ;;
|
|
3932
|
+
# pi installs npm packages under its own directory, not npm's global one.
|
|
3933
|
+
pi-subagents|pi-compact-tools|pi-web-access|pi-lens|pi-slash-anywhere|@juicesharp/rpiv-ask-user-question|billion-context|@juicesharp/rpiv-todo|pi-plugin-manager)
|
|
3934
|
+
v=$(json_field "$PI_DIR/npm/node_modules/$1/package.json" version) ;;
|
|
3935
|
+
esac
|
|
3936
|
+
printf '%s' "${v#v}"
|
|
3937
|
+
}
|
|
3938
|
+
|
|
3939
|
+
# One JSON field, by dotted path, read from a file. install_binary hands its
|
|
3940
|
+
# release document to python through the environment; this one cannot, because
|
|
3941
|
+
# herdr's manifest carries a whole changelog and a document that size overflows
|
|
3942
|
+
# the environment an exec is allowed. A file has no such ceiling.
|
|
3943
|
+
json_field() { # json_field <file> <dotted.path>
|
|
3944
|
+
py "$1" "$2" <<'PY'
|
|
3945
|
+
import json, sys
|
|
3946
|
+
try:
|
|
3947
|
+
d = json.load(open(sys.argv[1]))
|
|
3948
|
+
except (ValueError, OSError):
|
|
3949
|
+
raise SystemExit
|
|
3950
|
+
for k in sys.argv[2].split("."):
|
|
3951
|
+
d = d.get(k) if isinstance(d, dict) else None
|
|
3952
|
+
if d is None: raise SystemExit
|
|
3953
|
+
print(d)
|
|
3954
|
+
PY
|
|
3955
|
+
}
|
|
3956
|
+
|
|
3957
|
+
latest_version() { # latest_version <source> <reference> -> a version, or nothing
|
|
3958
|
+
[ "${ACSTACK_TW_OFFLINE:-0}" = "1" ] && return 0
|
|
3959
|
+
local url field tmp v="" tok="${GH_TOKEN:-${GITHUB_TOKEN:-}}"
|
|
3960
|
+
local -a auth=()
|
|
3961
|
+
case "$1" in
|
|
3962
|
+
github|ghext)
|
|
3963
|
+
url="https://api.github.com/repos/$2/releases/latest"; field=tag_name
|
|
3964
|
+
# An unauthenticated GitHub call gets 60 an hour, shared with everything
|
|
3965
|
+
# else on the machine. Ten tools in one run is a sixth of that. A token
|
|
3966
|
+
# raises it to 5000 and costs nothing to pass. It goes to GitHub and
|
|
3967
|
+
# nowhere else: the two hosts below never see it.
|
|
3968
|
+
[ -n "$tok" ] && auth=(-H "Authorization: Bearer $tok") ;;
|
|
3969
|
+
# herdr publishes its own manifest, and `herdr update` installs from it.
|
|
3970
|
+
herdr) url="https://herdr.dev/latest.json"; field=version ;;
|
|
3971
|
+
pypi) url="https://pypi.org/pypi/$2/json"; field=info.version ;;
|
|
3972
|
+
# A scoped name keeps its @ and escapes its slash.
|
|
3973
|
+
npm) url="https://registry.npmjs.org/${2/\//%2F}/latest"; field=version ;;
|
|
3974
|
+
*) return 0 ;;
|
|
3975
|
+
esac
|
|
3976
|
+
tmp=$(mktemp)
|
|
3977
|
+
curl -sSf "${auth[@]}" --max-time 10 "$url" -o "$tmp" 2>/dev/null \
|
|
3978
|
+
&& v=$(json_field "$tmp" "$field")
|
|
3979
|
+
rm -f "$tmp"
|
|
3980
|
+
printf '%s' "${v#v}"
|
|
3981
|
+
}
|
|
3982
|
+
|
|
3983
|
+
outdated_report() { # tab-separated rows on stdout, one per finding
|
|
3984
|
+
local name key src ref inst latest
|
|
3985
|
+
# Components that arrived since the last apply. See write_stamp for why this
|
|
3986
|
+
# reads the stamp rather than the user's own config: a config that overrides
|
|
3987
|
+
# four keys and defaults the rest is normal, and diffing against it would
|
|
3988
|
+
# report every defaulted component as new.
|
|
3989
|
+
if [ ! -f "$STAMP" ]; then
|
|
3990
|
+
# No stamp and no managed block is a machine apply has never touched.
|
|
3991
|
+
# Comparing versions there would report a workspace as current when none
|
|
3992
|
+
# of it is installed, so say the one true thing and stop.
|
|
3993
|
+
if ! grep -q "$BEGIN" "$HERDR_CFG" 2>/dev/null; then
|
|
3994
|
+
printf 'missing\tworkspace\tnever applied on this machine\n'
|
|
3995
|
+
return 0
|
|
3996
|
+
fi
|
|
3997
|
+
printf 'unknown\tcomponents\tno stamp yet, run apply once\n'
|
|
3998
|
+
elif [ -f "$EXAMPLE" ]; then
|
|
3999
|
+
# The row stays until apply installs the component and writes a new stamp.
|
|
4000
|
+
# Only the detail moves: once upgrade has appended the block, "not in your
|
|
4001
|
+
# config" is false, and a report that says it is stops being worth reading.
|
|
4002
|
+
ACSTACK_STAMP="$STAMP" py "$EXAMPLE" "$CFG" <<'PY'
|
|
4003
|
+
import os, re, sys
|
|
4004
|
+
import yaml
|
|
4005
|
+
had = {l.strip() for l in open(os.environ["ACSTACK_STAMP"])
|
|
4006
|
+
if l.strip() and not l.startswith("#")}
|
|
4007
|
+
mine = open(sys.argv[2]).read() if os.path.isfile(sys.argv[2]) else ""
|
|
4008
|
+
for k in (yaml.safe_load(open(sys.argv[1])) or {}):
|
|
4009
|
+
if k in had:
|
|
4010
|
+
continue
|
|
4011
|
+
if re.search(r"(?m)^%s:" % re.escape(k), mine):
|
|
4012
|
+
print("new\t%s\tnew component, run apply to install it" % k)
|
|
4013
|
+
else:
|
|
4014
|
+
print("new\t%s\tnew component, not in your config" % k)
|
|
4015
|
+
PY
|
|
4016
|
+
fi
|
|
4017
|
+
|
|
4018
|
+
while IFS=$'\t' read -r name key src ref _; do
|
|
4019
|
+
[ "$(cfg_get "$key.enabled" "$(enabled_default "$key")")" = "true" ] || continue
|
|
4020
|
+
# A fork build's version comes from a local checkout, so a release tag says
|
|
4021
|
+
# nothing about it.
|
|
4022
|
+
[ "$name" = "tuicr" ] && [ "$(cfg_get tuicr.build_from_fork false)" = "true" ] && continue
|
|
4023
|
+
# pi update never moves a pinned npm source, so a newer release is nothing
|
|
4024
|
+
# upgrade could fetch. Say so once instead of reporting it stale forever.
|
|
4025
|
+
if pi_npm_pin "$name"; then
|
|
4026
|
+
printf 'unknown\t%s\tpinned to %s in pi, which pi update never moves\n' "$name" "$(pi_state pkg "npm:$name")"
|
|
4027
|
+
continue
|
|
4028
|
+
fi
|
|
4029
|
+
inst=$(installed_version "$name")
|
|
4030
|
+
[ -n "$inst" ] || continue # not installed is apply's business, not this one
|
|
4031
|
+
latest=$(latest_version "$src" "$ref")
|
|
4032
|
+
if [ -z "$latest" ]; then
|
|
4033
|
+
[ "${ACSTACK_TW_OFFLINE:-0}" = "1" ] \
|
|
4034
|
+
|| printf 'unknown\t%s\tcould not reach %s\n' "$name" "$src"
|
|
4035
|
+
elif [ "$inst" != "$latest" ]; then
|
|
4036
|
+
printf 'stale\t%s\t%s -> %s\n' "$name" "$inst" "$latest"
|
|
4037
|
+
fi
|
|
4038
|
+
done < <(version_table)
|
|
4039
|
+
|
|
4040
|
+
# The ACStack package on pi is pinned to a release, so it falls behind the
|
|
4041
|
+
# moment this plugin updates. Both versions are local, so this needs no
|
|
4042
|
+
# network and reports even offline.
|
|
4043
|
+
if [ "$(cfg_get pi.enabled false)" = "true" ]; then
|
|
4044
|
+
local behind; behind=$(pi_acstack_behind)
|
|
4045
|
+
[ -n "$behind" ] && printf 'stale\tpi-acstack\t%s -> %s\n' "${behind%%$'\t'*}" "${behind#*$'\t'}"
|
|
4046
|
+
fi
|
|
4047
|
+
}
|
|
4048
|
+
|
|
4049
|
+
# Reinstalls over what is there, from the coordinates the table already holds.
|
|
4050
|
+
# Split out so the tuicr arm can guard the fork build and still reach it,
|
|
4051
|
+
# rather than carrying a second copy of the repository and the asset match.
|
|
4052
|
+
upgrade_from_table() { # upgrade_from_table <name>
|
|
4053
|
+
local repo match
|
|
4054
|
+
read -r repo match < <(version_table | awk -F'\t' -v n="$1" '$1==n{print $4, $5}')
|
|
4055
|
+
[ -n "${repo:-}" ] && [ "$repo" != "-" ] \
|
|
4056
|
+
|| { say "$1 is not a tool this script installs"; return 1; }
|
|
4057
|
+
INSTALL_FORCE=1 install_binary "$1" "$repo" "$match"
|
|
4058
|
+
# yazi's archive carries ya beside it, and the two must not drift.
|
|
4059
|
+
if [ "$1" = "yazi" ]; then
|
|
4060
|
+
INSTALL_FORCE=1 install_binary ya sxyazi/yazi "$match"
|
|
4061
|
+
fi
|
|
4062
|
+
}
|
|
4063
|
+
|
|
4064
|
+
upgrade_one() { # upgrade_one <name>
|
|
4065
|
+
if [ "$1" = "pi-acstack" ] \
|
|
4066
|
+
|| version_table | awk -F'\t' -v n="$1" '$1==n && $2=="pi" && $3=="npm"{found=1} END{exit !found}'; then
|
|
4067
|
+
find_pi
|
|
4068
|
+
fi
|
|
4069
|
+
case "$1" in
|
|
4070
|
+
herdr) herdr update >/dev/null 2>&1 \
|
|
4071
|
+
&& say "herdr updated" || say "herdr update failed" ;;
|
|
4072
|
+
gh-dash) gh extension upgrade dlvhdr/gh-dash >/dev/null 2>&1 \
|
|
4073
|
+
&& say "gh-dash upgraded" || say "gh-dash upgrade failed" ;;
|
|
4074
|
+
harlequin) uv tool upgrade harlequin >/dev/null 2>&1 \
|
|
4075
|
+
&& say "harlequin upgraded" || say "harlequin upgrade failed" ;;
|
|
4076
|
+
# With no target, pi update updates pi itself and no package.
|
|
4077
|
+
pi) if pi update >/dev/null 2>&1; then
|
|
4078
|
+
say "pi updated"
|
|
4079
|
+
patch_pi_win32_paste
|
|
4080
|
+
else
|
|
4081
|
+
say "pi update failed"
|
|
4082
|
+
fi ;;
|
|
4083
|
+
pi-subagents|pi-compact-tools|pi-web-access|pi-lens|pi-slash-anywhere|@juicesharp/rpiv-ask-user-question|billion-context|@juicesharp/rpiv-todo|pi-plugin-manager)
|
|
4084
|
+
if pi_npm_pin "$1"; then
|
|
4085
|
+
say "$1 is pinned to $(pi_state pkg "npm:$1") in pi, which pi update never moves"
|
|
4086
|
+
return 0
|
|
4087
|
+
fi
|
|
4088
|
+
pi update "npm:$1" >/dev/null 2>&1 \
|
|
4089
|
+
&& say "$1 updated" || say "$1 update failed" ;;
|
|
4090
|
+
# Only a pin behind this plugin. A newer pin is the user's choice, and an
|
|
4091
|
+
# absent or unpinned ACStack is apply's business.
|
|
4092
|
+
pi-acstack)
|
|
4093
|
+
[ -n "$(pi_acstack_behind)" ] || { say "pi-acstack: nothing behind this plugin to move"; return 0; }
|
|
4094
|
+
pi_add "$PI_ACSTACK@$(acstack_version)" "$PI_ACSTACK" ;;
|
|
4095
|
+
# Absent from the table, because it has no version flag for outdated to
|
|
4096
|
+
# read. Naming it is the only way to refresh it, so the arm it needs is
|
|
4097
|
+
# here rather than behind a table lookup that will never find it.
|
|
4098
|
+
mermaid-ascii)
|
|
4099
|
+
INSTALL_FORCE=1 install_binary mermaid-ascii AlexanderGrooff/mermaid-ascii Linux_x86_64 ;;
|
|
4100
|
+
# The fork build comes from a local checkout, so a release archive would
|
|
4101
|
+
# replace it with the stock binary and silently drop the patches it carries.
|
|
4102
|
+
# outdated already skips it; naming it here has to skip it too.
|
|
4103
|
+
tuicr)
|
|
4104
|
+
if [ "$(cfg_get tuicr.build_from_fork false)" = "true" ]; then
|
|
4105
|
+
say "tuicr is built from a fork here, so run apply to rebuild it"
|
|
4106
|
+
return 0
|
|
4107
|
+
fi
|
|
4108
|
+
upgrade_from_table tuicr ;;
|
|
4109
|
+
*) upgrade_from_table "$1" ;;
|
|
4110
|
+
esac
|
|
4111
|
+
}
|
|
4112
|
+
|
|
4113
|
+
case "$ACTION" in
|
|
4114
|
+
status)
|
|
4115
|
+
echo "config: $CFG $([ -f "$CFG" ] && echo '(found)' || echo '(missing, using defaults)')"
|
|
4116
|
+
find_pi
|
|
4117
|
+
for t in herdr delta diffnav fzf lazygit tuicr yazi glow mermaid-ascii harlequin gh nvim pi; do
|
|
4118
|
+
case "$t" in
|
|
4119
|
+
gh) k=gh_dash ;; glow|mermaid-ascii) k=markdown ;; fzf) k=diffnav ;;
|
|
4120
|
+
nvim) k=neovim ;;
|
|
4121
|
+
*) k="$t" ;;
|
|
4122
|
+
esac
|
|
4123
|
+
# neovim and pi default to off, so reading them with the default every
|
|
4124
|
+
# other one takes would report them enabled when the config says nothing.
|
|
4125
|
+
printf ' %-13s %-14s enabled=%s\n' "$t" "$(have "$t" && echo installed || echo 'not installed')" \
|
|
4126
|
+
"$(cfg_get "$k.enabled" "$(enabled_default "$k")")"
|
|
4127
|
+
done
|
|
4128
|
+
grep -q "$BEGIN" "$HERDR_CFG" 2>/dev/null && say "herdr managed block: present" || say "herdr managed block: absent"
|
|
4129
|
+
echo
|
|
4130
|
+
# Where a copy and an open actually land, which is the first thing to
|
|
4131
|
+
# check when o or y misbehaves on a machine reached over SSH.
|
|
4132
|
+
if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then
|
|
4133
|
+
say "clipboard: this machine has a display, using its own"
|
|
4134
|
+
elif [ -x "$BIN/xclip" ]; then
|
|
4135
|
+
say "clipboard: xclip stand-in, copying to your terminal over OSC 52"
|
|
4136
|
+
else
|
|
4137
|
+
say "clipboard: none - run apply, or copy will fail in gh-dash and friends"
|
|
4138
|
+
fi
|
|
4139
|
+
if grep -q 'vim\.g\.clipboard' "$NVIM_PLUGIN" 2>/dev/null; then
|
|
4140
|
+
say "neovim clipboard: OSC 52, written by this setup"
|
|
4141
|
+
elif mine="$(nvim_user_sets 'g\.clipboard')"; then
|
|
4142
|
+
say "neovim clipboard: set by $mine"
|
|
4143
|
+
elif [ "$(cfg_get neovim.enabled false)" = "true" ]; then
|
|
4144
|
+
say "neovim clipboard: none - run apply, or :%y+ fails over SSH"
|
|
4145
|
+
fi
|
|
4146
|
+
# Ask the opener itself rather than repeating its rules here. Two copies
|
|
4147
|
+
# of this decision drifting apart is exactly how o came to claim it had
|
|
4148
|
+
# opened a page it had not.
|
|
4149
|
+
case "$(acstack-open --why 2>/dev/null)" in
|
|
4150
|
+
command) say "browser: \$ACSTACK_OPEN_CMD = $ACSTACK_OPEN_CMD" ;;
|
|
4151
|
+
xdg-open) say "browser: xdg-open on this machine" ;;
|
|
4152
|
+
wslview|wsl-open|explorer.exe)
|
|
4153
|
+
say "browser: the Windows side of this machine" ;;
|
|
4154
|
+
opener) say "browser: your default browser on your laptop, through the opener" ;;
|
|
4155
|
+
bridge) say "browser: Chrome on your laptop, through the Playwriter bridge" ;;
|
|
4156
|
+
link) say "browser: none - o hands you a ctrl+clickable link and copies it" ;;
|
|
4157
|
+
clipboard) say "browser: none, and no terminal either - o copies and reports failure" ;;
|
|
4158
|
+
*) say "browser: unknown - run apply" ;;
|
|
4159
|
+
esac
|
|
4160
|
+
[ -f "$HOME/.cache/acstack/open.log" ] \
|
|
4161
|
+
&& say "last open: $(tail -1 "$HOME/.cache/acstack/open.log")"
|
|
4162
|
+
# What the pi component set up, read from pi's own files. Shown whenever
|
|
4163
|
+
# pi is here or asked for, because a pi installed by hand is still one the
|
|
4164
|
+
# user may want to know about.
|
|
4165
|
+
if have pi || [ "$(cfg_get pi.enabled false)" = "true" ]; then
|
|
4166
|
+
echo
|
|
4167
|
+
have pi && say "pi: $(installed_version pi)" || say "pi: not installed"
|
|
4168
|
+
for id in npm:pi-subagents "$PI_ACSTACK" "$PI_BILLION" npm:@juicesharp/rpiv-todo npm:pi-plugin-manager npm:@juicesharp/rpiv-ask-user-question npm:pi-web-access npm:pi-lens "$PI_SLASH_ANYWHERE" npm:pi-compact-tools; do
|
|
4169
|
+
src=$(pi_state pkg "$id") && say "pi package: $src" || say "pi package: $id not installed"
|
|
4170
|
+
done
|
|
4171
|
+
[ -e "$PI_RETRO_EXT" ] && say "pi agentic-retro extension: present" \
|
|
4172
|
+
|| say "pi agentic-retro extension: absent"
|
|
4173
|
+
grep -qsF "$PI_MD_BEGIN" "$PI_DIR"/AGENTS.override.md "$PI_DIR"/AGENTS.md "$PI_DIR"/AGENTS.MD \
|
|
4174
|
+
"$PI_DIR"/CLAUDE.md "$PI_DIR"/CLAUDE.MD \
|
|
4175
|
+
&& say "pi delegation line: present" || say "pi delegation line: absent"
|
|
4176
|
+
ta=$(json_field "$PI_SUBAGENT_CFG" toolActivation)
|
|
4177
|
+
say "pi toolActivation: ${ta:-not set}"
|
|
4178
|
+
[ -f "$PI_DIR/auth.json" ] && say "pi login: logged in" \
|
|
4179
|
+
|| say "pi login: not logged in - run pi, then /login"
|
|
4180
|
+
fi
|
|
4181
|
+
if [ "$(cfg_get tuicr.build_from_fork false)" = "true" ]; then
|
|
4182
|
+
echo
|
|
4183
|
+
say "tuicr: local fork build - this is meant to be temporary"
|
|
4184
|
+
if have gh; then
|
|
4185
|
+
for pr in 607 633; do
|
|
4186
|
+
state=$(gh pr view "$pr" --repo agavra/tuicr --json state -q .state 2>/dev/null || echo "unknown")
|
|
4187
|
+
printf ' agavra/tuicr#%s %s\n' "$pr" "$state"
|
|
4188
|
+
done
|
|
4189
|
+
say "both MERGED? set tuicr.build_from_fork: false and re-run apply"
|
|
4190
|
+
say "check the merged key names first - review can rename them"
|
|
4191
|
+
else
|
|
4192
|
+
say "install gh to have this check agavra/tuicr#607 and #633 for you"
|
|
4193
|
+
fi
|
|
4194
|
+
fi
|
|
4195
|
+
;;
|
|
4196
|
+
apply)
|
|
4197
|
+
require_yaml
|
|
4198
|
+
arch_supported || exit 1
|
|
4199
|
+
# Every setting has a default, so a missing config is a working setup and
|
|
4200
|
+
# not an error. Say so anyway: the alternative is a run that looks like it
|
|
4201
|
+
# read your settings and did not.
|
|
4202
|
+
[ -f "$CFG" ] || say "no config at $CFG, so every setting takes its default"
|
|
4203
|
+
[ "$(cfg_get enabled true)" = "true" ] || { echo "terminal workspace disabled in config"; exit 0; }
|
|
4204
|
+
if [ "$(cfg_get herdr.enabled true)" = "true" ] && ! have herdr; then
|
|
4205
|
+
# herdr ships an installer that picks the right build and verifies a
|
|
4206
|
+
# checksum. Prefer it over matching release asset names ourselves.
|
|
4207
|
+
curl -fsSL https://herdr.dev/install.sh | sh >/dev/null 2>&1 \
|
|
4208
|
+
&& say "herdr installed" || say "herdr install failed, see herdr.dev"
|
|
4209
|
+
fi
|
|
4210
|
+
[ "$(cfg_get delta.enabled true)" = "true" ] && install_binary delta dandavison/delta x86_64-unknown-linux-gnu
|
|
4211
|
+
[ "$(cfg_get diffnav.enabled true)" = "true" ] && install_binary diffnav dlvhdr/diffnav Linux_x86_64
|
|
4212
|
+
# fzf drives the comparison picker behind acstack-diff pick.
|
|
4213
|
+
[ "$(cfg_get diffnav.enabled true)" = "true" ] && install_binary fzf junegunn/fzf linux_amd64
|
|
4214
|
+
[ "$(cfg_get lazygit.enabled true)" = "true" ] && install_binary lazygit jesseduffield/lazygit linux_x86_64
|
|
4215
|
+
write_helpers
|
|
4216
|
+
[ "$(cfg_get tuicr.enabled true)" = "true" ] && apply_tuicr
|
|
4217
|
+
apply_yazi
|
|
4218
|
+
apply_markdown
|
|
4219
|
+
apply_neovim
|
|
4220
|
+
[ "$(cfg_get herdr.enabled true)" = "true" ] && apply_herdr
|
|
4221
|
+
[ "$(cfg_get herdr.enabled true)" = "true" ] && apply_herdr_numbers
|
|
4222
|
+
[ "$(cfg_get delta.enabled true)" = "true" ] && apply_delta
|
|
4223
|
+
[ "$(cfg_get remote.enabled true)" = "true" ] && apply_remote
|
|
4224
|
+
[ "$(cfg_get gh_dash.enabled true)" = "true" ] && apply_ghdash
|
|
4225
|
+
[ "$(cfg_get harlequin.enabled true)" = "true" ] && apply_harlequin
|
|
4226
|
+
[ "$(cfg_get pi.enabled false)" = "true" ] && apply_pi
|
|
4227
|
+
echo "done. open a herdr session and press prefix+? to see the keymap"
|
|
4228
|
+
# Last, so a run that died half way leaves no stamp claiming success.
|
|
4229
|
+
write_stamp
|
|
4230
|
+
;;
|
|
4231
|
+
outdated)
|
|
4232
|
+
# Exit 2 means "cannot tell", and a missing PyYAML is exactly that.
|
|
4233
|
+
# require_yaml exits 1, which every caller reads as "there is something to
|
|
4234
|
+
# fetch" and acts on findings this run was never able to produce.
|
|
4235
|
+
python3 -c 'import yaml' 2>/dev/null || {
|
|
4236
|
+
echo "PyYAML is required to read $CFG, so nothing here can be compared" >&2
|
|
4237
|
+
exit 2; }
|
|
4238
|
+
[ -f "$EXAMPLE" ] || { echo "no config.example.yaml beside $0" >&2; exit 2; }
|
|
4239
|
+
out=$(outdated_report)
|
|
4240
|
+
[ -n "$out" ] || exit 0
|
|
4241
|
+
printf '%s\n' "$out"
|
|
4242
|
+
# An unknown is not a finding: nothing can be done about a tool with no
|
|
4243
|
+
# version flag or a GitHub that did not answer. Only a real one exits 1.
|
|
4244
|
+
printf '%s\n' "$out" | grep -qE '^(missing|new|stale)' && exit 1
|
|
4245
|
+
exit 0
|
|
4246
|
+
;;
|
|
4247
|
+
upgrade)
|
|
4248
|
+
require_yaml
|
|
4249
|
+
arch_supported || exit 1
|
|
4250
|
+
shift
|
|
4251
|
+
names="$*"
|
|
4252
|
+
report=""
|
|
4253
|
+
if [ -z "$names" ]; then
|
|
4254
|
+
report=$(outdated_report)
|
|
4255
|
+
names=$(printf '%s\n' "$report" | awk -F'\t' '$1=="stale"{print $2}')
|
|
4256
|
+
fi
|
|
4257
|
+
for n in $names; do upgrade_one "$n"; done
|
|
4258
|
+
|
|
4259
|
+
# A new component is a config edit, not a download. Append the block the
|
|
4260
|
+
# example ships, comments and all, rather than rewriting the file through
|
|
4261
|
+
# yaml - a round trip through the parser drops every comment the user has.
|
|
4262
|
+
printf '%s\n' "$report" | awk -F'\t' '$1=="new"{print $2}' | while read -r comp; do
|
|
4263
|
+
[ -n "$comp" ] || continue
|
|
4264
|
+
ACSTACK_COMP="$comp" py "$EXAMPLE" "$CFG" <<'PY'
|
|
4265
|
+
import os, re, sys
|
|
4266
|
+
comp = os.environ["ACSTACK_COMP"]
|
|
4267
|
+
ship = open(sys.argv[1]).read()
|
|
4268
|
+
# The block runs from the component's own key to the next top-level key. A
|
|
4269
|
+
# top-level key is the only thing that starts in column zero and ends in a
|
|
4270
|
+
# colon, which is what makes a plain text slice safe here.
|
|
4271
|
+
m = re.search(r"(?ms)^%s:.*?(?=^\S+:|\Z)" % re.escape(comp), ship)
|
|
4272
|
+
if not m:
|
|
4273
|
+
raise SystemExit
|
|
4274
|
+
block = m.group(0).rstrip() + "\n"
|
|
4275
|
+
mine = open(sys.argv[2]).read() if os.path.isfile(sys.argv[2]) else ""
|
|
4276
|
+
if re.search(r"(?m)^%s:" % re.escape(comp), mine):
|
|
4277
|
+
raise SystemExit
|
|
4278
|
+
open(sys.argv[2], "a").write(("" if mine.endswith("\n\n") else "\n") + block)
|
|
4279
|
+
print(" %s added to your config, with its comments" % comp)
|
|
4280
|
+
PY
|
|
4281
|
+
done
|
|
4282
|
+
|
|
4283
|
+
say "run apply to install anything new"
|
|
4284
|
+
;;
|
|
4285
|
+
disable)
|
|
4286
|
+
DISABLE_USAGE="usage: setup.sh disable <herdr|delta|diffnav|gh_dash|harlequin|lazygit|neovim|pi|tuicr>"
|
|
4287
|
+
comp="${2:?$DISABLE_USAGE}"
|
|
4288
|
+
case "$comp" in
|
|
4289
|
+
delta) for k in core.pager interactive.diffFilter delta.navigate delta.line-numbers; do
|
|
4290
|
+
git config --global --unset "$k" 2>/dev/null
|
|
4291
|
+
done
|
|
4292
|
+
say "delta unset as git pager"
|
|
4293
|
+
say "now set delta.enabled: false in $CFG so apply does not restore it" ;;
|
|
4294
|
+
herdr)
|
|
4295
|
+
py "$HERDR_CFG" "$BEGIN" "$END" <<'PY'
|
|
4296
|
+
import re, sys, os
|
|
4297
|
+
p, b, e = sys.argv[1:4]
|
|
4298
|
+
if os.path.isfile(p):
|
|
4299
|
+
t = open(p).read()
|
|
4300
|
+
open(p, "w").write(re.sub(re.escape(b) + r".*?" + re.escape(e) + r"\n?", "", t, flags=re.S))
|
|
4301
|
+
PY
|
|
4302
|
+
say "managed block removed from herdr config"
|
|
4303
|
+
say "now set herdr.enabled: false in $CFG so apply does not restore it" ;;
|
|
4304
|
+
neovim)
|
|
4305
|
+
# A whole file of ours, so turning it off is a deletion. Anything the
|
|
4306
|
+
# user wrote by hand under that name is theirs and stays.
|
|
4307
|
+
if nvim_ours; then
|
|
4308
|
+
rm -f "$NVIM_PLUGIN"
|
|
4309
|
+
say "neovim plugin file removed"
|
|
4310
|
+
fi
|
|
4311
|
+
say "now set neovim.enabled: false in $CFG so apply does not restore it" ;;
|
|
4312
|
+
pi)
|
|
4313
|
+
# Only what apply added. The pi binary, its logins and its sessions
|
|
4314
|
+
# stay, and so does anything the user installed into pi themselves.
|
|
4315
|
+
remove_pi
|
|
4316
|
+
say "now set pi.enabled: false in $CFG so apply does not restore it" ;;
|
|
4317
|
+
diffnav|gh_dash|harlequin|lazygit|tuicr)
|
|
4318
|
+
# Only this component's popup goes away. Rebuild the managed block so
|
|
4319
|
+
# the rest of the keymap survives.
|
|
4320
|
+
require_yaml
|
|
4321
|
+
ACSTACK_CFG="$CFG" py "$comp" <<'PY'
|
|
4322
|
+
import os, sys, yaml
|
|
4323
|
+
p = os.environ["ACSTACK_CFG"]
|
|
4324
|
+
c = yaml.safe_load(open(p)) or {}
|
|
4325
|
+
c.setdefault(sys.argv[1], {})["enabled"] = False
|
|
4326
|
+
yaml.safe_dump(c, open(p, "w"), sort_keys=False)
|
|
4327
|
+
PY
|
|
4328
|
+
apply_herdr
|
|
4329
|
+
say "$comp disabled, rest of the keymap left in place" ;;
|
|
4330
|
+
yazi|markdown|remote)
|
|
4331
|
+
# These three have no keys to remove and no git settings to unset, so
|
|
4332
|
+
# there is nothing here to carry out. Refusing says so; the arm that
|
|
4333
|
+
# was missing let the script exit 0 in silence, which reads as done.
|
|
4334
|
+
say "$comp has no disable step"
|
|
4335
|
+
say "set $comp.enabled: false in $CFG and run apply instead"
|
|
4336
|
+
exit 1 ;;
|
|
4337
|
+
*) echo "$comp is not a component. $DISABLE_USAGE" >&2; exit 1 ;;
|
|
4338
|
+
esac
|
|
4339
|
+
;;
|
|
4340
|
+
uninstall)
|
|
4341
|
+
"$0" disable herdr; "$0" disable delta; "$0" disable neovim; "$0" disable pi
|
|
4342
|
+
# By prefix, which is why helpers are named acstack-*: anything this
|
|
4343
|
+
# script ever wrote goes, including helpers retired in an older version.
|
|
4344
|
+
herdr plugin unlink acstack.herdr-numbers >/dev/null 2>&1 || true
|
|
4345
|
+
rm -rf "$HOME/.config/acstack/herdr-numbers"
|
|
4346
|
+
rm -f "$BIN"/acstack-*
|
|
4347
|
+
# The xclip stand-in is the one managed file without the prefix.
|
|
4348
|
+
grep -q "Installed by acstack" "$BIN/xclip" 2>/dev/null && rm -f "$BIN/xclip"
|
|
4349
|
+
# Every file apply leaves a marked region in. Removing the region is the
|
|
4350
|
+
# same edit `disable herdr` makes, so whatever the user wrote around it
|
|
4351
|
+
# comes through untouched. Left behind, the yazi regions keep routing
|
|
4352
|
+
# markdown at acstack-md, a helper this command has just deleted.
|
|
4353
|
+
ACSTACK_ARGS="$BEGIN|$END" py \
|
|
4354
|
+
"$HOME/.config/tuicr/config.toml" \
|
|
4355
|
+
"$HOME/.config/yazi/yazi.toml" \
|
|
4356
|
+
"$HOME/.config/yazi/keymap.toml" <<'PY'
|
|
4357
|
+
import os, re, sys
|
|
4358
|
+
b, e = os.environ["ACSTACK_ARGS"].split("|")
|
|
4359
|
+
for p in sys.argv[1:]:
|
|
4360
|
+
if os.path.isfile(p):
|
|
4361
|
+
t = open(p).read()
|
|
4362
|
+
open(p, "w").write(re.sub(re.escape(b) + r".*?" + re.escape(e) + r"\n?", "", t, flags=re.S))
|
|
4363
|
+
PY
|
|
4364
|
+
# gh-dash reads YAML that python rewrites whole, so a marker comment never
|
|
4365
|
+
# survives the round trip. What identifies acstack's work there is the name
|
|
4366
|
+
# on each keybinding, the same handle apply_ghdash uses to replace its own
|
|
4367
|
+
# entries, plus the two page keys it binds. Sections, repo paths and every
|
|
4368
|
+
# other keybinding belong to the user and stay.
|
|
4369
|
+
if [ -f "$GHDASH_CFG" ] && python3 -c 'import yaml' 2>/dev/null; then
|
|
4370
|
+
py "$GHDASH_CFG" <<'PY'
|
|
4371
|
+
import sys, yaml
|
|
4372
|
+
p = sys.argv[1]
|
|
4373
|
+
cfg = yaml.safe_load(open(p)) or {}
|
|
4374
|
+
MANAGED = ("review (tuicr)", "tree diff", "agent review", "open in browser")
|
|
4375
|
+
kbs = cfg.get("keybindings") or {}
|
|
4376
|
+
for scope in ("prs", "issues"):
|
|
4377
|
+
if scope in kbs:
|
|
4378
|
+
kbs[scope] = [k for k in kbs[scope] or [] if k.get("name") not in MANAGED]
|
|
4379
|
+
if not kbs[scope]:
|
|
4380
|
+
del kbs[scope]
|
|
4381
|
+
if "universal" in kbs:
|
|
4382
|
+
kbs["universal"] = [k for k in kbs["universal"] or []
|
|
4383
|
+
if k.get("builtin") not in ("pageUp", "pageDown")]
|
|
4384
|
+
if not kbs["universal"]:
|
|
4385
|
+
del kbs["universal"]
|
|
4386
|
+
if kbs:
|
|
4387
|
+
cfg["keybindings"] = kbs
|
|
4388
|
+
else:
|
|
4389
|
+
cfg.pop("keybindings", None)
|
|
4390
|
+
yaml.safe_dump(cfg, open(p, "w"), sort_keys=False, default_flow_style=False, width=200)
|
|
4391
|
+
PY
|
|
4392
|
+
say "gh-dash keybindings removed"
|
|
4393
|
+
fi
|
|
4394
|
+
# The one tool uv owns rather than this script. Leaving it behind leaves an
|
|
4395
|
+
# environment nothing on the machine points at any more.
|
|
4396
|
+
if have uv && uv tool list 2>/dev/null | grep -q '^harlequin '; then
|
|
4397
|
+
uv tool uninstall harlequin >/dev/null 2>&1 && say "harlequin removed"
|
|
4398
|
+
fi
|
|
4399
|
+
say "managed config and helpers removed. binaries left in place, except"
|
|
4400
|
+
say "harlequin, which uv owns whole rather than as a file in $BIN"
|
|
4401
|
+
say "pi stays too, with its logins and sessions: npm uninstall -g @earendil-works/pi-coding-agent"
|
|
4402
|
+
;;
|
|
4403
|
+
*) echo "usage: setup.sh [status|apply|outdated|upgrade|disable <component>|uninstall]"; exit 1 ;;
|
|
4404
|
+
esac
|