acstack 0.49.0 → 0.49.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/assets/terminal-workspace/helpers/acstack-bridge +185 -0
  3. package/assets/terminal-workspace/helpers/acstack-clip +88 -0
  4. package/assets/terminal-workspace/helpers/acstack-dash +25 -0
  5. package/assets/terminal-workspace/helpers/acstack-db +86 -0
  6. package/assets/terminal-workspace/helpers/acstack-diff +268 -0
  7. package/assets/terminal-workspace/helpers/acstack-edit +25 -0
  8. package/assets/terminal-workspace/helpers/acstack-gh +47 -0
  9. package/assets/terminal-workspace/helpers/acstack-herdr-renumber +93 -0
  10. package/assets/terminal-workspace/helpers/acstack-herdr-tab +49 -0
  11. package/assets/terminal-workspace/helpers/acstack-md +401 -0
  12. package/assets/terminal-workspace/helpers/acstack-md-lib +219 -0
  13. package/assets/terminal-workspace/helpers/acstack-open +217 -0
  14. package/assets/terminal-workspace/helpers/acstack-popup +13 -0
  15. package/assets/terminal-workspace/helpers/acstack-view +33 -0
  16. package/assets/terminal-workspace/helpers/xclip +31 -0
  17. package/assets/terminal-workspace/lib/apply-ghdash.sh +78 -0
  18. package/assets/terminal-workspace/lib/apply-herdr-numbers.sh +49 -0
  19. package/assets/terminal-workspace/lib/apply-herdr.sh +308 -0
  20. package/assets/terminal-workspace/lib/apply-tools.sh +293 -0
  21. package/assets/terminal-workspace/lib/apply-tuicr.sh +133 -0
  22. package/assets/terminal-workspace/lib/apply-yazi.sh +389 -0
  23. package/assets/terminal-workspace/lib/commands.sh +296 -0
  24. package/assets/terminal-workspace/lib/core.sh +184 -0
  25. package/assets/terminal-workspace/lib/helper-install.sh +35 -0
  26. package/assets/terminal-workspace/lib/pi-packages.sh +331 -0
  27. package/assets/terminal-workspace/lib/pi-state.sh +479 -0
  28. package/assets/terminal-workspace/lib/versions.sh +254 -0
  29. package/assets/terminal-workspace/setup.sh +24 -4637
  30. package/cli/package.json +1 -1
  31. package/package.json +1 -1
  32. package/skills/handoff/SKILL.md +14 -14
  33. package/skills/handoff/launch-herdr.md +15 -4
  34. package/skills/implement/SKILL.md +5 -0
@@ -0,0 +1,296 @@
1
+ acstack_tw_main() {
2
+ ACTION="${1:-status}"
3
+ case "$ACTION" in
4
+ status)
5
+ echo "config: $CFG $([ -f "$CFG" ] && echo '(found)' || echo '(missing, using defaults)')"
6
+ find_pi
7
+ for t in herdr delta diffnav fzf lazygit tuicr yazi glow mermaid-ascii harlequin gh nvim pi; do
8
+ case "$t" in
9
+ gh) k=gh_dash ;; glow|mermaid-ascii) k=markdown ;; fzf) k=diffnav ;;
10
+ nvim) k=neovim ;;
11
+ *) k="$t" ;;
12
+ esac
13
+ # neovim and pi default to off, so reading them with the default every
14
+ # other one takes would report them enabled when the config says nothing.
15
+ printf ' %-13s %-14s enabled=%s\n' "$t" "$(have "$t" && echo installed || echo 'not installed')" \
16
+ "$(cfg_get "$k.enabled" "$(enabled_default "$k")")"
17
+ done
18
+ grep -q "$BEGIN" "$HERDR_CFG" 2>/dev/null && say "herdr managed block: present" || say "herdr managed block: absent"
19
+ echo
20
+ # Where a copy and an open actually land, which is the first thing to
21
+ # check when o or y misbehaves on a machine reached over SSH.
22
+ if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then
23
+ say "clipboard: this machine has a display, using its own"
24
+ elif [ -x "$BIN/xclip" ]; then
25
+ say "clipboard: xclip stand-in, copying to your terminal over OSC 52"
26
+ else
27
+ say "clipboard: none - run apply, or copy will fail in gh-dash and friends"
28
+ fi
29
+ if grep -q 'vim\.g\.clipboard' "$NVIM_PLUGIN" 2>/dev/null; then
30
+ say "neovim clipboard: OSC 52, written by this setup"
31
+ elif mine="$(nvim_user_sets 'g\.clipboard')"; then
32
+ say "neovim clipboard: set by $mine"
33
+ elif [ "$(cfg_get neovim.enabled false)" = "true" ]; then
34
+ say "neovim clipboard: none - run apply, or :%y+ fails over SSH"
35
+ fi
36
+ # Ask the opener itself rather than repeating its rules here. Two copies
37
+ # of this decision drifting apart is exactly how o came to claim it had
38
+ # opened a page it had not.
39
+ case "$(acstack-open --why 2>/dev/null)" in
40
+ command) say "browser: \$ACSTACK_OPEN_CMD = $ACSTACK_OPEN_CMD" ;;
41
+ xdg-open) say "browser: xdg-open on this machine" ;;
42
+ wslview|wsl-open|explorer.exe)
43
+ say "browser: the Windows side of this machine" ;;
44
+ opener) say "browser: your default browser on your laptop, through the opener" ;;
45
+ bridge) say "browser: Chrome on your laptop, through the Playwriter bridge" ;;
46
+ link) say "browser: none - o hands you a ctrl+clickable link and copies it" ;;
47
+ clipboard) say "browser: none, and no terminal either - o copies and reports failure" ;;
48
+ *) say "browser: unknown - run apply" ;;
49
+ esac
50
+ [ -f "$HOME/.cache/acstack/open.log" ] \
51
+ && say "last open: $(tail -1 "$HOME/.cache/acstack/open.log")"
52
+ # What the pi component set up, read from pi's own files. Shown whenever
53
+ # pi is here or asked for, because a pi installed by hand is still one the
54
+ # user may want to know about.
55
+ if have pi || [ "$(cfg_get pi.enabled false)" = "true" ]; then
56
+ echo
57
+ have pi && say "pi: $(installed_version pi)" || say "pi: not installed"
58
+ for id in "$PI_ACSTACK" $(version_table | awk -F'\t' '$2=="pi" && $3=="npm" && $1!="pi"{print $1}' | while read -r name; do pi_npm_package "$name"; done); do
59
+ src=$(pi_state pkg "$id") && say "pi package: $src" || say "pi package: $id not installed"
60
+ done
61
+ [ -e "$PI_RETRO_EXT" ] && say "pi agentic-retro extension: present" \
62
+ || say "pi agentic-retro extension: absent"
63
+ grep -qsF "$PI_MD_BEGIN" "$PI_DIR"/AGENTS.override.md "$PI_DIR"/AGENTS.md "$PI_DIR"/AGENTS.MD \
64
+ "$PI_DIR"/CLAUDE.md "$PI_DIR"/CLAUDE.MD \
65
+ && say "pi delegation line: present" || say "pi delegation line: absent"
66
+ ta=$(json_field "$PI_SUBAGENT_CFG" toolActivation)
67
+ say "pi toolActivation: ${ta:-not set}"
68
+ [ -f "$PI_DIR/auth.json" ] && say "pi login: logged in" \
69
+ || say "pi login: not logged in - run pi, then /login"
70
+ fi
71
+ if [ "$(cfg_get tuicr.build_from_fork false)" = "true" ]; then
72
+ echo
73
+ say "tuicr: local fork build - this is meant to be temporary"
74
+ if have gh; then
75
+ for pr in 607 633; do
76
+ state=$(gh pr view "$pr" --repo agavra/tuicr --json state -q .state 2>/dev/null || echo "unknown")
77
+ printf ' agavra/tuicr#%s %s\n' "$pr" "$state"
78
+ done
79
+ say "both MERGED? set tuicr.build_from_fork: false and re-run apply"
80
+ say "check the merged key names first - review can rename them"
81
+ else
82
+ say "install gh to have this check agavra/tuicr#607 and #633 for you"
83
+ fi
84
+ fi
85
+ ;;
86
+ apply)
87
+ require_yaml
88
+ arch_supported || exit 1
89
+ # Every setting has a default, so a missing config is a working setup and
90
+ # not an error. Say so anyway: the alternative is a run that looks like it
91
+ # read your settings and did not.
92
+ [ -f "$CFG" ] || say "no config at $CFG, so every setting takes its default"
93
+ [ "$(cfg_get enabled true)" = "true" ] || { echo "terminal workspace disabled in config"; exit 0; }
94
+ if [ "$(cfg_get herdr.enabled true)" = "true" ] && ! have herdr; then
95
+ # herdr ships an installer that picks the right build and verifies a
96
+ # checksum. Prefer it over matching release asset names ourselves.
97
+ curl -fsSL https://herdr.dev/install.sh | sh >/dev/null 2>&1 \
98
+ && say "herdr installed" || say "herdr install failed, see herdr.dev"
99
+ fi
100
+ [ "$(cfg_get delta.enabled true)" = "true" ] && install_binary delta dandavison/delta x86_64-unknown-linux-gnu
101
+ [ "$(cfg_get diffnav.enabled true)" = "true" ] && install_binary diffnav dlvhdr/diffnav Linux_x86_64
102
+ # fzf drives the comparison picker behind acstack-diff pick.
103
+ [ "$(cfg_get diffnav.enabled true)" = "true" ] && install_binary fzf junegunn/fzf linux_amd64
104
+ [ "$(cfg_get lazygit.enabled true)" = "true" ] && install_binary lazygit jesseduffield/lazygit linux_x86_64
105
+ write_helpers
106
+ [ "$(cfg_get tuicr.enabled true)" = "true" ] && apply_tuicr
107
+ apply_yazi
108
+ apply_markdown
109
+ apply_neovim
110
+ [ "$(cfg_get herdr.enabled true)" = "true" ] && apply_herdr
111
+ [ "$(cfg_get herdr.enabled true)" = "true" ] && apply_herdr_numbers
112
+ [ "$(cfg_get delta.enabled true)" = "true" ] && apply_delta
113
+ [ "$(cfg_get remote.enabled true)" = "true" ] && apply_remote
114
+ [ "$(cfg_get gh_dash.enabled true)" = "true" ] && apply_ghdash
115
+ [ "$(cfg_get harlequin.enabled true)" = "true" ] && apply_harlequin
116
+ [ "$(cfg_get pi.enabled false)" = "true" ] && apply_pi
117
+ echo "done. open a herdr session and press prefix+? to see the keymap"
118
+ # Last, so a run that died half way leaves no stamp claiming success.
119
+ write_stamp
120
+ ;;
121
+ outdated)
122
+ # Exit 2 means "cannot tell", and a missing PyYAML is exactly that.
123
+ # require_yaml exits 1, which every caller reads as "there is something to
124
+ # fetch" and acts on findings this run was never able to produce.
125
+ python3 -c 'import yaml' 2>/dev/null || {
126
+ echo "PyYAML is required to read $CFG, so nothing here can be compared" >&2
127
+ exit 2; }
128
+ [ -f "$EXAMPLE" ] || { echo "no config.example.yaml beside $0" >&2; exit 2; }
129
+ out=$(outdated_report)
130
+ [ -n "$out" ] || exit 0
131
+ printf '%s\n' "$out"
132
+ # An unknown is not a finding: nothing can be done about a tool with no
133
+ # version flag or a GitHub that did not answer. Only a real one exits 1.
134
+ printf '%s\n' "$out" | grep -qE '^(missing|new|stale)' && exit 1
135
+ exit 0
136
+ ;;
137
+ upgrade)
138
+ require_yaml
139
+ arch_supported || exit 1
140
+ shift
141
+ names="$*"
142
+ report=""
143
+ if [ -z "$names" ]; then
144
+ report=$(outdated_report)
145
+ names=$(printf '%s\n' "$report" | awk -F'\t' '$1=="stale"{print $2}')
146
+ fi
147
+ for n in $names; do upgrade_one "$n"; done
148
+
149
+ # A new component is a config edit, not a download. Append the block the
150
+ # example ships, comments and all, rather than rewriting the file through
151
+ # yaml - a round trip through the parser drops every comment the user has.
152
+ printf '%s\n' "$report" | awk -F'\t' '$1=="new"{print $2}' | while read -r comp; do
153
+ [ -n "$comp" ] || continue
154
+ ACSTACK_COMP="$comp" py "$EXAMPLE" "$CFG" <<'PY'
155
+ import os, re, sys
156
+ comp = os.environ["ACSTACK_COMP"]
157
+ ship = open(sys.argv[1]).read()
158
+ # The block runs from the component's own key to the next top-level key. A
159
+ # top-level key is the only thing that starts in column zero and ends in a
160
+ # colon, which is what makes a plain text slice safe here.
161
+ m = re.search(r"(?ms)^%s:.*?(?=^\S+:|\Z)" % re.escape(comp), ship)
162
+ if not m:
163
+ raise SystemExit
164
+ block = m.group(0).rstrip() + "\n"
165
+ mine = open(sys.argv[2]).read() if os.path.isfile(sys.argv[2]) else ""
166
+ if re.search(r"(?m)^%s:" % re.escape(comp), mine):
167
+ raise SystemExit
168
+ open(sys.argv[2], "a").write(("" if mine.endswith("\n\n") else "\n") + block)
169
+ print(" %s added to your config, with its comments" % comp)
170
+ PY
171
+ done
172
+
173
+ say "run apply to install anything new"
174
+ ;;
175
+ disable)
176
+ DISABLE_USAGE="usage: setup.sh disable <herdr|delta|diffnav|gh_dash|harlequin|lazygit|neovim|pi|tuicr>"
177
+ comp="${2:?$DISABLE_USAGE}"
178
+ case "$comp" in
179
+ delta) for k in core.pager interactive.diffFilter delta.navigate delta.line-numbers; do
180
+ git config --global --unset "$k" 2>/dev/null
181
+ done
182
+ say "delta unset as git pager"
183
+ say "now set delta.enabled: false in $CFG so apply does not restore it" ;;
184
+ herdr)
185
+ py "$HERDR_CFG" "$BEGIN" "$END" <<'PY'
186
+ import re, sys, os
187
+ p, b, e = sys.argv[1:4]
188
+ if os.path.isfile(p):
189
+ t = open(p).read()
190
+ open(p, "w").write(re.sub(re.escape(b) + r".*?" + re.escape(e) + r"\n?", "", t, flags=re.S))
191
+ PY
192
+ say "managed block removed from herdr config"
193
+ say "now set herdr.enabled: false in $CFG so apply does not restore it" ;;
194
+ neovim)
195
+ # A whole file of ours, so turning it off is a deletion. Anything the
196
+ # user wrote by hand under that name is theirs and stays.
197
+ if nvim_ours; then
198
+ rm -f "$NVIM_PLUGIN"
199
+ say "neovim plugin file removed"
200
+ fi
201
+ say "now set neovim.enabled: false in $CFG so apply does not restore it" ;;
202
+ pi)
203
+ # Only what apply added. The pi binary, its logins and its sessions
204
+ # stay, and so does anything the user installed into pi themselves.
205
+ remove_pi
206
+ say "now set pi.enabled: false in $CFG so apply does not restore it" ;;
207
+ diffnav|gh_dash|harlequin|lazygit|tuicr)
208
+ # Only this component's popup goes away. Rebuild the managed block so
209
+ # the rest of the keymap survives.
210
+ require_yaml
211
+ ACSTACK_CFG="$CFG" py "$comp" <<'PY'
212
+ import os, sys, yaml
213
+ p = os.environ["ACSTACK_CFG"]
214
+ c = yaml.safe_load(open(p)) or {}
215
+ c.setdefault(sys.argv[1], {})["enabled"] = False
216
+ yaml.safe_dump(c, open(p, "w"), sort_keys=False)
217
+ PY
218
+ apply_herdr
219
+ say "$comp disabled, rest of the keymap left in place" ;;
220
+ yazi|markdown|remote)
221
+ # These three have no keys to remove and no git settings to unset, so
222
+ # there is nothing here to carry out. Refusing says so; the arm that
223
+ # was missing let the script exit 0 in silence, which reads as done.
224
+ say "$comp has no disable step"
225
+ say "set $comp.enabled: false in $CFG and run apply instead"
226
+ exit 1 ;;
227
+ *) echo "$comp is not a component. $DISABLE_USAGE" >&2; exit 1 ;;
228
+ esac
229
+ ;;
230
+ uninstall)
231
+ "$0" disable herdr; "$0" disable delta; "$0" disable neovim; "$0" disable pi
232
+ # By prefix, which is why helpers are named acstack-*: anything this
233
+ # script ever wrote goes, including helpers retired in an older version.
234
+ herdr plugin unlink acstack.herdr-numbers >/dev/null 2>&1 || true
235
+ rm -rf "$HOME/.config/acstack/herdr-numbers"
236
+ rm -f "$BIN"/acstack-*
237
+ # The xclip stand-in is the one managed file without the prefix.
238
+ grep -q "Installed by acstack" "$BIN/xclip" 2>/dev/null && rm -f "$BIN/xclip"
239
+ # Every file apply leaves a marked region in. Removing the region is the
240
+ # same edit `disable herdr` makes, so whatever the user wrote around it
241
+ # comes through untouched. Left behind, the yazi regions keep routing
242
+ # markdown at acstack-md, a helper this command has just deleted.
243
+ ACSTACK_ARGS="$BEGIN|$END" py \
244
+ "$HOME/.config/tuicr/config.toml" \
245
+ "$HOME/.config/yazi/yazi.toml" \
246
+ "$HOME/.config/yazi/keymap.toml" <<'PY'
247
+ import os, re, sys
248
+ b, e = os.environ["ACSTACK_ARGS"].split("|")
249
+ for p in sys.argv[1:]:
250
+ if os.path.isfile(p):
251
+ t = open(p).read()
252
+ open(p, "w").write(re.sub(re.escape(b) + r".*?" + re.escape(e) + r"\n?", "", t, flags=re.S))
253
+ PY
254
+ # gh-dash reads YAML that python rewrites whole, so a marker comment never
255
+ # survives the round trip. What identifies acstack's work there is the name
256
+ # on each keybinding, the same handle apply_ghdash uses to replace its own
257
+ # entries, plus the two page keys it binds. Sections, repo paths and every
258
+ # other keybinding belong to the user and stay.
259
+ if [ -f "$GHDASH_CFG" ] && python3 -c 'import yaml' 2>/dev/null; then
260
+ py "$GHDASH_CFG" <<'PY'
261
+ import sys, yaml
262
+ p = sys.argv[1]
263
+ cfg = yaml.safe_load(open(p)) or {}
264
+ MANAGED = ("review (tuicr)", "tree diff", "agent review", "open in browser")
265
+ kbs = cfg.get("keybindings") or {}
266
+ for scope in ("prs", "issues"):
267
+ if scope in kbs:
268
+ kbs[scope] = [k for k in kbs[scope] or [] if k.get("name") not in MANAGED]
269
+ if not kbs[scope]:
270
+ del kbs[scope]
271
+ if "universal" in kbs:
272
+ kbs["universal"] = [k for k in kbs["universal"] or []
273
+ if k.get("builtin") not in ("pageUp", "pageDown")]
274
+ if not kbs["universal"]:
275
+ del kbs["universal"]
276
+ if kbs:
277
+ cfg["keybindings"] = kbs
278
+ else:
279
+ cfg.pop("keybindings", None)
280
+ yaml.safe_dump(cfg, open(p, "w"), sort_keys=False, default_flow_style=False, width=200)
281
+ PY
282
+ say "gh-dash keybindings removed"
283
+ fi
284
+ # The one tool uv owns rather than this script. Leaving it behind leaves an
285
+ # environment nothing on the machine points at any more.
286
+ if have uv && uv tool list 2>/dev/null | grep -q '^harlequin '; then
287
+ uv tool uninstall harlequin >/dev/null 2>&1 && say "harlequin removed"
288
+ fi
289
+ say "managed config and helpers removed. binaries left in place, except"
290
+ say "harlequin, which uv owns whole rather than as a file in $BIN"
291
+ say "pi stays too, with its logins and sessions: npm uninstall -g @earendil-works/pi-coding-agent"
292
+ ;;
293
+ *) echo "usage: setup.sh [status|apply|outdated|upgrade|disable <component>|uninstall]"; exit 1 ;;
294
+ esac
295
+
296
+ }
@@ -0,0 +1,184 @@
1
+ have() { command -v "$1" >/dev/null 2>&1; }
2
+
3
+ require_yaml() {
4
+ python3 -c 'import yaml' 2>/dev/null && return 0
5
+ echo "PyYAML is required to read $CFG" >&2
6
+ echo "Install it (pip install --user pyyaml) and run again. Refusing to" >&2
7
+ echo "continue, because without it every setting falls back to a default" >&2
8
+ echo "and your config is silently ignored." >&2
9
+ exit 1
10
+ }
11
+
12
+ arch_supported() {
13
+ case "$(uname -m)" in
14
+ x86_64|amd64) return 0 ;;
15
+ *) say "prebuilt binaries are x86_64 only, and this is $(uname -m)"
16
+ say "install herdr, delta, diffnav, and lazygit yourself, then run apply again"
17
+ return 1 ;;
18
+ esac
19
+ }
20
+ say() { printf ' %s\n' "$*"; }
21
+
22
+ py() { python3 - "$@"; }
23
+
24
+ # Reads one dotted path. require_yaml runs first, so a fallback here means
25
+ # the key is absent, never that yaml failed to import.
26
+ cfg_get() { # cfg_get <dotted.path> <default>
27
+ ACSTACK_CFG="$CFG" py "$1" "${2:-}" <<'PY'
28
+ import os, sys
29
+ import yaml
30
+ p = os.environ["ACSTACK_CFG"]
31
+ if not os.path.isfile(p):
32
+ print(sys.argv[2]); raise SystemExit
33
+ d = yaml.safe_load(open(p)) or {}
34
+ for k in sys.argv[1].split("."):
35
+ d = d.get(k) if isinstance(d, dict) else None
36
+ if d is None: break
37
+ if isinstance(d, bool): d = "true" if d else "false"
38
+ print(d if d is not None else sys.argv[2])
39
+ PY
40
+ }
41
+
42
+ # neovim and pi write into a config the user built, so they start off. Every
43
+ # other component owns its files outright and starts on.
44
+ enabled_default() { # enabled_default <component>
45
+ case "$1" in neovim|pi) echo false ;; *) echo true ;; esac
46
+ }
47
+
48
+ # The example config that shipped beside this script. Both `outdated` and the
49
+ # stamp read it, so resolve it once.
50
+ EXAMPLE="$SCRIPT_DIR/config.example.yaml"
51
+ STAMP="$(dirname "$CFG")/terminal-workspace.applied"
52
+ # What the pi component changed, so disable undoes that and nothing else. A
53
+ # package or a setting the user already had is theirs, and stays.
54
+ PI_OWNED="$(dirname "$CFG")/terminal-workspace.pi.json"
55
+
56
+ # Reads a yaml list into one item per line. cfg_get prints a python list repr
57
+ # for a list value, which no caller can use.
58
+ cfg_get_list() { # cfg_get_list <dotted.path> <space-separated default>
59
+ ACSTACK_CFG="$CFG" py "$1" "${2:-}" <<'PY'
60
+ import os, sys
61
+ import yaml
62
+ p = os.environ["ACSTACK_CFG"]
63
+ d = (yaml.safe_load(open(p)) or {}) if os.path.isfile(p) else {}
64
+ for k in sys.argv[1].split("."):
65
+ d = d.get(k) if isinstance(d, dict) else None
66
+ if d is None: break
67
+ print("\n".join(str(x) for x in d) if isinstance(d, list) else sys.argv[2].replace(" ", "\n"))
68
+ PY
69
+ }
70
+
71
+ # What `apply` had to offer, recorded so a later run can tell what arrived
72
+ # since. Without it, a component added by an upgrade is invisible: cfg_get
73
+ # returns its default, apply installs it, and nobody is ever told it is there.
74
+ write_stamp() {
75
+ [ -f "$EXAMPLE" ] || return 0
76
+ mkdir -p "$(dirname "$STAMP")"
77
+ { echo "# written by acstack terminal-workspace apply, do not edit"
78
+ py "$EXAMPLE" <<'PY'
79
+ import sys
80
+ import yaml
81
+ for k in (yaml.safe_load(open(sys.argv[1])) or {}):
82
+ print(k)
83
+ PY
84
+ } > "$STAMP"
85
+ }
86
+
87
+ install_binary() { # install_binary <name> <repo> <asset-match>
88
+ local name="$1" repo="$2" match="$3"
89
+ # `upgrade` reinstalls over a binary that is already here. `apply` never
90
+ # does: it installs what is missing and leaves what is not.
91
+ if [ "${INSTALL_FORCE:-0}" != "1" ] && have "$name"; then
92
+ say "$name already installed"; return 0
93
+ fi
94
+ local url json
95
+ # py reads its script from stdin, so the release JSON has to arrive by
96
+ # environment rather than by pipe: a pipe here is silently swallowed by
97
+ # the heredoc and python then reads an empty stdin.
98
+ json=$(curl -sSf "https://api.github.com/repos/$repo/releases/latest" 2>/dev/null)
99
+ [ -z "$json" ] && { say "$name: could not reach the GitHub release API"; return 1; }
100
+ url=$(ACSTACK_JSON="$json" py "$match" <<'PY'
101
+ import json, os, sys
102
+ try:
103
+ d = json.loads(os.environ["ACSTACK_JSON"])
104
+ except ValueError:
105
+ raise SystemExit
106
+ for a in d.get("assets", []):
107
+ # yazi publishes .zip only; delta and glow publish .tar.gz. Take either.
108
+ if sys.argv[1] in a["name"] and a["name"].endswith((".tar.gz", ".tgz", ".zip")):
109
+ print(a["browser_download_url"]); break
110
+ PY
111
+ )
112
+ [ -z "$url" ] && { say "$name: no matching release asset, install manually"; return 1; }
113
+ # Explicit cleanup rather than `trap ... RETURN`: a RETURN trap stays
114
+ # registered for every later function return in this shell, where $tmp is
115
+ # gone and `set -u` then aborts the run.
116
+ local tmp found ar; tmp=$(mktemp -d)
117
+ case "$url" in *.zip) ar="$tmp/a.zip" ;; *) ar="$tmp/a.tgz" ;; esac
118
+ if ! curl -sL "$url" -o "$ar"; then
119
+ rm -rf "$tmp"; say "$name: download failed"; return 1
120
+ fi
121
+ case "$ar" in
122
+ *.zip) have unzip || { rm -rf "$tmp"; say "$name: needs unzip"; return 1; }
123
+ unzip -qo "$ar" -d "$tmp" || { rm -rf "$tmp"; say "$name: unzip failed"; return 1; } ;;
124
+ *) tar xzf "$ar" -C "$tmp" || { rm -rf "$tmp"; say "$name: untar failed"; return 1; } ;;
125
+ esac
126
+ found=$(find "$tmp" -type f -name "$name" | head -1)
127
+ if [ -z "$found" ]; then
128
+ rm -rf "$tmp"; say "$name: binary not found in archive"; return 1
129
+ fi
130
+ mkdir -p "$BIN"; cp "$found" "$BIN/$name"; chmod +x "$BIN/$name"
131
+ rm -rf "$tmp"
132
+ say "$name installed to $BIN/$name"
133
+ }
134
+
135
+ install_plain_github_binary() { # install_plain_github_binary <name> <repo> <asset-match>
136
+ local name="$1" repo="$2" match="$3"
137
+ if [ "${INSTALL_FORCE:-0}" != "1" ] && have "$name"; then
138
+ say "$name already installed"; return 0
139
+ fi
140
+ local json url tmp
141
+ json=$(curl -sSf "https://api.github.com/repos/$repo/releases/latest" 2>/dev/null)
142
+ [ -z "$json" ] && { say "$name: could not reach the GitHub release API"; return 1; }
143
+ url=$(ACSTACK_JSON="$json" py "$match" <<'PY'
144
+ import json, os, sys
145
+ try:
146
+ d = json.loads(os.environ["ACSTACK_JSON"])
147
+ except ValueError:
148
+ raise SystemExit
149
+ for a in d.get("assets", []):
150
+ if sys.argv[1] in a["name"]:
151
+ print(a["browser_download_url"]); break
152
+ PY
153
+ )
154
+ [ -z "$url" ] && { say "$name: no matching release asset, install manually"; return 1; }
155
+ tmp=$(mktemp)
156
+ if ! curl -fsSL "$url" -o "$tmp"; then
157
+ rm -f "$tmp"; say "$name: download failed"; return 1
158
+ fi
159
+ mkdir -p "$BIN"; cp "$tmp" "$BIN/$name"; chmod +x "$BIN/$name"
160
+ rm -f "$tmp"
161
+ say "$name installed to $BIN/$name"
162
+ }
163
+
164
+ install_npm_tool() { # install_npm_tool <command> <package>
165
+ local command="$1" package="$2"
166
+ if have "$command"; then say "$command already installed"; return 0; fi
167
+ have npm || { say "$command: npm not installed, skipping"; return 0; }
168
+ if npm install -g "$package" >/dev/null 2>&1; then
169
+ say "$command installed with npm"
170
+ else
171
+ say "$command install failed, run: npm install -g $package"
172
+ fi
173
+ }
174
+
175
+ install_uv_tool() { # install_uv_tool <command> <package>
176
+ local command="$1" package="$2"
177
+ if have "$command"; then say "$command already installed"; return 0; fi
178
+ have uv || { say "$command: uv not installed, skipping"; return 0; }
179
+ if uv tool install "$package" >/dev/null 2>&1; then
180
+ say "$command installed with uv"
181
+ else
182
+ say "$command install failed, run: uv tool install $package"
183
+ fi
184
+ }
@@ -0,0 +1,35 @@
1
+ write_helpers() {
2
+ mkdir -p "$BIN"
3
+ # Helpers this script used to write and no longer does. Upgrading otherwise
4
+ # leaves them on PATH, shadowing nothing but confusing everything.
5
+ rm -f "$BIN"/acstack-files "$BIN"/acstack-files-tab \
6
+ "$BIN"/acstack-yazi-tab "$BIN"/acstack-tab "$BIN"/acstack-renumber
7
+
8
+ local src name
9
+ for src in "$SCRIPT_DIR"/helpers/acstack-*; do
10
+ [ -f "$src" ] || continue
11
+ name=${src##*/}
12
+ cp "$src" "$BIN/$name"
13
+ chmod +x "$BIN/$name"
14
+ done
15
+
16
+ # The one helper with a path in the config. The source holds the default, so
17
+ # this carries nothing but a value the user chose.
18
+ ACSTACK_DB_CFG="$(cfg_get harlequin.config_path "~/.config/acstack/harlequin.toml")" \
19
+ py "$BIN/acstack-db" <<'PY'
20
+ import os, re, sys
21
+ # A bash single-quoted literal, so a path with a space or a quote in it reaches
22
+ # the helper as one word rather than as shell syntax.
23
+ v = "'" + os.environ["ACSTACK_DB_CFG"].replace("'", "'\\''") + "'"
24
+ p = sys.argv[1]
25
+ t = open(p).read()
26
+ t, n = re.subn(r"(?m)^DB_CFG_DEFAULT=.*# acstack:config_path$",
27
+ lambda m: "DB_CFG_DEFAULT=" + v + " # acstack:config_path", t)
28
+ if n:
29
+ open(p, "w").write(t)
30
+ PY
31
+
32
+ say "helpers written: acstack-diff, acstack-dash, acstack-db, acstack-md, acstack-view"
33
+ say "remote helpers written: acstack-clip, acstack-open, acstack-bridge"
34
+ say "herdr helpers written: acstack-popup, acstack-herdr-tab, acstack-herdr-renumber, acstack-edit"
35
+ }