acstack 0.48.5 → 0.49.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) 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/dist/index.js +8 -4
  31. package/cli/dist/index.js.map +2 -2
  32. package/cli/package.json +1 -1
  33. package/package.json +1 -1
  34. package/skills/archive/SKILL.md +3 -0
  35. package/skills/grilling/SKILL.md +2 -1
  36. package/skills/handoff/SKILL.md +14 -14
  37. package/skills/handoff/launch-herdr.md +15 -4
  38. package/skills/implement/SKILL.md +19 -3
  39. package/skills/map/SKILL.md +24 -24
  40. package/skills/map/alignment.md +38 -0
@@ -0,0 +1,308 @@
1
+ apply_herdr() {
2
+ have herdr || { say "herdr not installed, skipping keymap"; return 0; }
3
+ # Every popup runs through acstack-popup. disable reaches here without
4
+ # apply, so after an upgrade the helper may not exist yet.
5
+ [ -x "$BIN/acstack-popup" ] || write_helpers
6
+ mkdir -p "$(dirname "$HERDR_CFG")"; touch "$HERDR_CFG"
7
+ local mod wt diffkey dashkey filekey filetabkey difftabkey dashtabkey
8
+ local pickkey picktabkey gitkey gittabkey dbkey dbtabkey scrollbars
9
+ local theme themeauto themelight toast labels panelsort
10
+ mod=$(cfg_get herdr.chord_modifier alt)
11
+ wt=$(cfg_get herdr.worktrees_directory "~/.herdr/worktrees")
12
+ # f, not d: Windows Terminal keeps alt+shift+d for "duplicate pane", so the
13
+ # tab half of a d pair never reaches herdr. Both diff keys sit on f instead.
14
+ diffkey=$(cfg_get diffnav.popup_key "alt+f")
15
+ dashkey=$(cfg_get gh_dash.popup_key "alt+i")
16
+ filekey=$(cfg_get yazi.popup_key "alt+y")
17
+ filetabkey=$(cfg_get yazi.tab_key "alt+shift+y")
18
+ difftabkey=$(cfg_get diffnav.tab_key "alt+shift+f")
19
+ dashtabkey=$(cfg_get gh_dash.tab_key "alt+shift+i")
20
+ pickkey=$(cfg_get diffnav.pick_key "alt+x")
21
+ picktabkey=$(cfg_get diffnav.pick_tab_key "alt+shift+x")
22
+ gitkey=$(cfg_get lazygit.popup_key "alt+g")
23
+ gittabkey=$(cfg_get lazygit.tab_key "alt+shift+g")
24
+ dbkey=$(cfg_get harlequin.popup_key "alt+q")
25
+ dbtabkey=$(cfg_get harlequin.tab_key "alt+shift+q")
26
+ # herdr counts the scrollbar column inside the pane rect but reports the
27
+ # full rect width to the program running in the pane. A full-screen app then
28
+ # writes one column more than it has, so every wrapped row starts a column
29
+ # left of the row above it. Turning the scrollbar off gives the column back.
30
+ scrollbars=$(cfg_get herdr.pane_scrollbars false)
31
+ # Four settings that are not keys. Each one is empty by default and then
32
+ # written only when the config names it, so a machine that says nothing
33
+ # keeps whatever herdr already paints. The exception is the pane label,
34
+ # which is written either way: several agents in several panes is what this
35
+ # workspace is for, and an unlabelled pane does not say which agent it holds.
36
+ theme=$(cfg_get herdr.theme.name "")
37
+ themeauto=$(cfg_get herdr.theme.auto_switch true)
38
+ themelight=$(cfg_get herdr.theme.light "")
39
+ toast=$(cfg_get herdr.toast_delivery "")
40
+ labels=$(cfg_get herdr.agent_labels_on_pane_borders true)
41
+ panelsort=$(cfg_get herdr.agent_panel_sort "")
42
+ [ "$(cfg_get diffnav.enabled true)" = "true" ] \
43
+ || { diffkey=""; difftabkey=""; pickkey=""; picktabkey=""; }
44
+ [ "$(cfg_get gh_dash.enabled true)" = "true" ] || { dashkey=""; dashtabkey=""; }
45
+ [ "$(cfg_get yazi.enabled true)" = "true" ] || { filekey=""; filetabkey=""; }
46
+ [ "$(cfg_get lazygit.enabled true)" = "true" ] || { gitkey=""; gittabkey=""; }
47
+ [ "$(cfg_get harlequin.enabled true)" = "true" ] || { dbkey=""; dbtabkey=""; }
48
+
49
+ 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'
50
+ import os, re, sys
51
+ path = sys.argv[1]
52
+ # herdr runs a popup with its server's PATH, which can lack $BIN. Each popup
53
+ # goes through acstack-popup by absolute path, which puts $BIN back on PATH.
54
+ popup = os.path.join(os.environ["ACSTACK_BIN"], "acstack-popup")
55
+ (mod, wt, diffkey, dashkey, filekey, filetabkey, difftabkey, dashtabkey,
56
+ pickkey, picktabkey, gitkey, gittabkey, dbkey, dbtabkey, scrollbars,
57
+ theme, themeauto, themelight, toast, labels, panelsort,
58
+ begin, end) = os.environ["ACSTACK_ARGS"].split("|")
59
+
60
+ # key, command, description, herdr custom-command type, popup size.
61
+ # type "shell" takes no size: herdr rejects width/height on a non-popup.
62
+ CUSTOM = [
63
+ (diffkey, "acstack-diff", "diff: branch vs dev", "popup", "90%"),
64
+ (difftabkey, "acstack-herdr-tab diff acstack-diff", "diff: branch vs dev (tab)", "shell", None),
65
+ (pickkey, "acstack-diff pick", "diff: pick what to compare", "popup", "90%"),
66
+ (picktabkey, "acstack-herdr-tab diffpick acstack-diff pick",
67
+ "diff: pick what to compare (tab)", "shell", None),
68
+ (dashkey, "acstack-dash", "PR dashboard", "popup", "95%"),
69
+ (dashtabkey, "acstack-herdr-tab dash acstack-dash", "PR dashboard (tab)", "shell", None),
70
+ (filekey, "yazi", "file tree", "popup", "95%"),
71
+ (filetabkey, "acstack-herdr-tab yazi yazi", "file tree (tab)", "shell", None),
72
+ (gitkey, "lazygit", "git: stage, commit, push", "popup", "90%"),
73
+ (gittabkey, "acstack-herdr-tab lazygit lazygit",
74
+ "git: stage, commit, push (tab)", "shell", None),
75
+ (dbkey, "acstack-db", "db: harlequin SQL editor", "popup", "90%"),
76
+ (dbtabkey, "acstack-herdr-tab harlequin acstack-db",
77
+ "db: harlequin SQL editor (tab)", "shell", None),
78
+ ]
79
+
80
+ def custom_blocks():
81
+ out = []
82
+ for key, cmd, desc, typ, size in CUSTOM:
83
+ if not key:
84
+ continue
85
+ if typ == "popup":
86
+ cmd = f"{popup} {cmd}"
87
+ out += ["", "[[keys.command]]", f'key = "{key}"', f'type = "{typ}"',
88
+ f'command = "{cmd}"', f'description = "{desc}"']
89
+ if size:
90
+ out += [f'width = "{size}"', f'height = "{size}"']
91
+ return out
92
+ text = open(path).read() if os.path.isfile(path) else ""
93
+ # Drop any previous managed region so this is idempotent.
94
+ text = re.sub(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", "", text, flags=re.S)
95
+
96
+ keys = []
97
+ if mod != "none":
98
+ m = mod
99
+ keys = [
100
+ f'focus_agent = "{m}+1..9"',
101
+ f'focus_pane_left = ["prefix+h", "{m}+h"]',
102
+ f'focus_pane_down = ["prefix+j", "{m}+j"]',
103
+ f'focus_pane_up = ["prefix+k", "{m}+k"]',
104
+ f'focus_pane_right = ["prefix+l", "{m}+l"]',
105
+ f'next_agent = "{m}+n"',
106
+ f'previous_agent = "{m}+u"',
107
+ f'next_tab = ["prefix+n", "{m}+right"]',
108
+ f'previous_tab = ["prefix+p", "{m}+left"]',
109
+ f'next_workspace = ["{m}+down"]',
110
+ f'previous_workspace = ["{m}+up"]',
111
+ # herdr puts switch_tab on prefix+1..9 by default. Move it aside so
112
+ # switch_workspace below can have the plain digits.
113
+ f'switch_tab = "prefix+{m}+1..9"',
114
+ f'toggle_sidebar = ["prefix+b", "{m}+s"]',
115
+ # g belongs to git only when lazygit is on. alt+g is lazygit and
116
+ # alt+shift+g is its tab, so goto moves off the letter on both layers:
117
+ # prefix+g one key away from alt+g reads as the same thing. With
118
+ # lazygit off, nothing wants the letter and goto keeps it.
119
+ ('goto = "prefix+t"' if gitkey else f'goto = ["prefix+g", "{m}+g"]'),
120
+ f'zoom = ["prefix+z", "{m}+z"]',
121
+ f'last_pane = "{m}+a"',
122
+ f'new_tab = ["prefix+c", "{m}+c"]',
123
+ f'new_workspace = ["prefix+shift+n", "{m}+w"]',
124
+ # Also off g, for the same reason, and back on it when lazygit is off.
125
+ (f'new_worktree = "{m}+r"' if gitkey
126
+ else f'new_worktree = ["prefix+shift+g", "{m}+r"]'),
127
+ f'split_vertical = ["prefix+v", "{m}+e"]',
128
+ f'split_horizontal = ["prefix+minus", "{m}+minus"]',
129
+ ]
130
+
131
+ # herdr leaves switch_workspace unbound, so without this there is no way to
132
+ # reach a workspace by number at all. It is prefix-only, so it is written even
133
+ # when the chord family is off: opting out of alt chords should not cost you
134
+ # workspace numbers.
135
+ keys.append('switch_workspace = "prefix+1..9"')
136
+
137
+ # See the comment on `scrollbars` above for what the scrollbar column costs.
138
+ # The label is written either way; the sort order only when the config names
139
+ # one, because herdr's own default is a reasonable answer and this script has
140
+ # no better one.
141
+ ui = [
142
+ "pane_scrollbars = " + ("true" if scrollbars == "true" else "false"),
143
+ "show_agent_labels_on_pane_borders = " + ("true" if labels == "true" else "false"),
144
+ ]
145
+ if panelsort:
146
+ ui.append(f'agent_panel_sort = "{panelsort}"')
147
+
148
+ # A theme, and a toast. Both tables stay empty unless the config names a value,
149
+ # so a machine that says nothing about either keeps what herdr already does.
150
+ #
151
+ # auto_switch asks the attached client for its background colour over OSC 11
152
+ # and picks dark_name or light_name from the answer. That is the setting that
153
+ # lets one herdr server serve a dark terminal on one device and a light one on
154
+ # another - an e-ink panel among them - without either client changing the
155
+ # other. dark_name repeats `name` because a client that answers nothing at all
156
+ # falls back to dark.
157
+ themelines = []
158
+ if theme:
159
+ themelines = [f'name = "{theme}"']
160
+ if themelight:
161
+ themelines += [
162
+ "auto_switch = " + ("true" if themeauto == "true" else "false"),
163
+ f'dark_name = "{theme}"',
164
+ f'light_name = "{themelight}"',
165
+ ]
166
+
167
+ toastlines = [f'delivery = "{toast}"'] if toast else []
168
+
169
+ # TOML forbids a duplicate key, so a hand-written keymap that already sets
170
+ # something this script manages would make the merged file unparseable and
171
+ # herdr would reject the lot. Drop our own keys from the user's [keys] table,
172
+ # and any [[keys.command]] bound to a key we are about to claim, before
173
+ # inserting. Anything we do not manage is left exactly as it was.
174
+ def names(lines):
175
+ return {k.split("=", 1)[0].strip() for k in lines if "=" in k}
176
+
177
+
178
+ managed = names(keys)
179
+ # One set per table. An empty set strips nothing, so a table this run writes
180
+ # no value into leaves whatever the user wrote in it exactly as it was.
181
+ MANAGED_BY_TABLE = {"[keys]": managed, "[ui]": names(ui),
182
+ "[theme]": names(themelines), "[ui.toast]": names(toastlines)}
183
+ # A theme is one setting spelled across four keys, so it is claimed whole or
184
+ # not at all. Claiming only what gets written leaves a hand-written
185
+ # `auto_switch` and `light_name` beside the new `name`, and a light client then
186
+ # keeps the theme the user is replacing.
187
+ if themelines:
188
+ MANAGED_BY_TABLE["[theme]"] = {"name", "auto_switch", "dark_name", "light_name"}
189
+ claimed = {key for key, *_ in CUSTOM if key}
190
+
191
+
192
+ def strip_managed(table, names):
193
+ # Drop the settings this script manages, and the comment block that
194
+ # introduced each one. Removing the setting alone strands its comment: the
195
+ # user is left with a run of bare headings describing settings that are no
196
+ # longer there, which reads as a broken config.
197
+ #
198
+ # A value can run over several lines, because every key here holds an array
199
+ # and TOML lets one span as many lines as it likes. Dropping the first line
200
+ # alone strands the rest as bare list items, which is invalid TOML - and
201
+ # herdr answers that by rejecting the whole file, so one hand-written
202
+ # binding written that way would cost the user every binding.
203
+ out, pending, depth = [], [], 0
204
+ for ln in table:
205
+ if depth > 0:
206
+ depth += ln.count("[") - ln.count("]")
207
+ continue
208
+ stripped = ln.strip()
209
+ if stripped.startswith("#") or not stripped:
210
+ pending.append(ln)
211
+ continue
212
+ if "=" in ln and ln.split("=", 1)[0].strip() in names:
213
+ pending = []
214
+ value = ln.split("=", 1)[1]
215
+ depth = value.count("[") - value.count("]")
216
+ continue
217
+ out.extend(pending)
218
+ pending = []
219
+ out.append(ln)
220
+ out.extend(pending)
221
+ return out
222
+
223
+
224
+ # A table header on its own line, and nothing else. `line.startswith("[")` is
225
+ # not enough: a value spanning several lines puts its own list items at the
226
+ # start of a line, and ` [1, 2],` would then read as a new table.
227
+ HEADER = re.compile(r"^\s*\[\[?[^\[\]]*\]\]?\s*(?:#.*)?$")
228
+
229
+
230
+ def tables(lines):
231
+ current, depth = [], 0
232
+ for line in lines:
233
+ if depth <= 0 and HEADER.match(line) and current:
234
+ yield current
235
+ current = []
236
+ # Only outside a multi-line value, for the same reason.
237
+ if not HEADER.match(line):
238
+ depth += line.count("[") - line.count("]")
239
+ depth = max(depth, 0)
240
+ current.append(line)
241
+ if current:
242
+ yield current
243
+
244
+
245
+ kept = []
246
+ for table in tables(text.splitlines()):
247
+ header = table[0].strip()
248
+ # The managed block re-declares [worktrees] in full, and TOML forbids
249
+ # declaring a table twice.
250
+ if header == "[worktrees]":
251
+ continue
252
+ if header == "[[keys.command]]":
253
+ bound = re.search(r'(?m)^\s*key\s*=\s*"([^"]+)"', "\n".join(table))
254
+ if bound and bound.group(1) in claimed:
255
+ continue
256
+ if header in MANAGED_BY_TABLE:
257
+ table = strip_managed(table, MANAGED_BY_TABLE[header])
258
+ kept.extend(table)
259
+ text = "\n".join(kept)
260
+ if text and not text.endswith("\n"):
261
+ text += "\n"
262
+
263
+ def merge(text, header, lines):
264
+ # Merge into a table the user already declared rather than declaring a
265
+ # second one, because TOML forbids declaring the same table twice. Returns
266
+ # the text and whether the merge happened.
267
+ # re.escape, because a sub-table header carries a dot: an unescaped
268
+ # "ui.toast" matches "[ui-toast]" too.
269
+ head = r"^\[" + re.escape(header) + r"\][ \t]*\n"
270
+ if not re.search(head, text, flags=re.M):
271
+ return text, False
272
+ region = begin + "\n" + "\n".join(lines) + "\n" + end
273
+ return re.sub(head, f"[{header}]\n" + region + "\n",
274
+ text, count=1, flags=re.M), True
275
+
276
+
277
+ block = []
278
+ for header, lines in (("keys", keys), ("ui", ui),
279
+ ("theme", themelines), ("ui.toast", toastlines)):
280
+ if not lines:
281
+ continue
282
+ text, merged = merge(text, header, lines)
283
+ if merged:
284
+ continue
285
+ if block:
286
+ block.append("")
287
+ block += [f"[{header}]"] + lines
288
+
289
+ # [[keys.command]] and [worktrees] are top-level, so they can never sit inside
290
+ # a merged region and always land in a block of their own at the end.
291
+ block += custom_blocks() + ["", "[worktrees]", f'directory = "{wt}"']
292
+ text = text.rstrip("\n") + "\n\n" + "\n".join([begin] + block + [end]) + "\n"
293
+
294
+ open(path, "w").write(text)
295
+ PY
296
+ if herdr config check 2>&1 | grep -q "^config: ok"; then
297
+ herdr server reload-config >/dev/null 2>&1
298
+ say "herdr keymap applied and reloaded"
299
+ else
300
+ say "herdr config check failed, review $HERDR_CFG"
301
+ herdr config check 2>&1 | sed 's/^/ /' | head -5
302
+ fi
303
+
304
+ local integ; integ=$(cfg_get herdr.integration none)
305
+ if [ "$integ" != "none" ] && [ -n "$integ" ]; then
306
+ herdr integration install "$integ" >/dev/null 2>&1 && say "herdr $integ state hook installed"
307
+ fi
308
+ }
@@ -0,0 +1,293 @@
1
+ apply_markdown() {
2
+ [ "$(cfg_get markdown.enabled true)" = "true" ] || return 0
3
+ install_binary mermaid-ascii AlexanderGrooff/mermaid-ascii Linux_x86_64
4
+ install_binary glow charmbracelet/glow Linux_x86_64
5
+ # Vendored so the preview page pulls nothing from a CDN: it works offline,
6
+ # and on a managed laptop there is no third-party fetch to explain.
7
+ local share="$HOME/.local/share/acstack"
8
+ mkdir -p "$share"
9
+ if [ ! -s "$share/mermaid.min.js" ]; then
10
+ curl -fsSL -o "$share/mermaid.min.js" \
11
+ "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js" \
12
+ && say "mermaid.js vendored to $share" \
13
+ || say "mermaid.js download failed, the preview will fall back to a CDN"
14
+ fi
15
+ python3 -c 'import markdown' 2>/dev/null \
16
+ || say "acstack-md --serve needs python markdown: pip install --user markdown"
17
+ have chafa || say "optional: apt install chafa, to draw images as text"
18
+ }
19
+
20
+ # The plugin file's name is ours by convention alone, and the directory around
21
+ # it is the user's. A file of that name this script did not write is theirs,
22
+ # so apply must not overwrite it and disable must not delete it. It says so
23
+ # where it refuses, so both paths report it the same way.
24
+ nvim_ours() {
25
+ [ ! -e "$NVIM_PLUGIN" ] && return 0
26
+ grep -q "$NVIM_MARK" "$NVIM_PLUGIN" 2>/dev/null && return 0
27
+ say "neovim: $NVIM_PLUGIN was written by hand, left alone"
28
+ return 1
29
+ }
30
+
31
+ # The dot is appended to LazyVim's lualine section, and LazyVim loads every
32
+ # file under lua/plugins. So a lualine override the user wrote in a file of
33
+ # their own does exactly what this component does, and installing on top of it
34
+ # draws two dots side by side. Print the file that already does it, if any.
35
+ nvim_dot_elsewhere() {
36
+ local dir="$HOME/.config/nvim/lua/plugins" f
37
+ [ -d "$dir" ] || return 1
38
+ for f in "$dir"/*.lua; do
39
+ [ -e "$f" ] || continue
40
+ [ "$f" = "$NVIM_PLUGIN" ] && continue
41
+ # Both, not either: a lualine override that says nothing about a modified
42
+ # buffer is not this dot, and neither is a modified check outside lualine.
43
+ grep -q 'lualine' "$f" 2>/dev/null || continue
44
+ grep -q 'vim\.bo\.modified' "$f" 2>/dev/null || continue
45
+ printf '%s\n' "$f"
46
+ return 0
47
+ done
48
+ return 1
49
+ }
50
+
51
+ # Print the first lua file of the user's own that sets a pattern, ignoring
52
+ # comment lines, since a setting tried and commented out decides nothing.
53
+ nvim_user_sets() {
54
+ local f
55
+ while IFS= read -r f; do
56
+ grep -v '^[[:space:]]*--' "$f" 2>/dev/null | grep -q "$1" || continue
57
+ printf '%s\n' "$f"
58
+ return 0
59
+ done < <(find "$HOME/.config/nvim" -name '*.lua' ! -path "$NVIM_PLUGIN" 2>/dev/null | sort)
60
+ return 1
61
+ }
62
+
63
+ # The clipboard part of the plugin file. The comment inside it says why.
64
+ NVIM_CLIPBOARD_LUA='
65
+ -- Over SSH, neovim finds no clipboard and :%y+ fails. Copy goes out over
66
+ -- OSC 52, which asks the terminal you sit at to put the text on its own
67
+ -- clipboard. Paste replays what this neovim copied last, because Windows
68
+ -- Terminal refuses OSC 52 reads. Use the terminal paste key for anything else.
69
+ local function set(v)
70
+ return v ~= nil and v ~= ""
71
+ end
72
+ if not (set(vim.env.DISPLAY) or set(vim.env.WAYLAND_DISPLAY) or set(vim.env.TMUX)
73
+ or vim.fn.has("mac") == 1 or vim.fn.has("wsl") == 1) then
74
+ local osc52 = require("vim.ui.clipboard.osc52")
75
+ local last = {}
76
+ local function copy(reg)
77
+ local send = osc52.copy(reg)
78
+ return function(lines, regtype)
79
+ last[reg] = { lines, regtype }
80
+ send(lines)
81
+ end
82
+ end
83
+ local function paste(reg)
84
+ return function()
85
+ return last[reg] or { { "" }, "v" }
86
+ end
87
+ end
88
+ vim.g.clipboard = {
89
+ name = "OSC 52 (acstack)",
90
+ copy = { ["+"] = copy("+"), ["*"] = copy("*") },
91
+ paste = { ["+"] = paste("+"), ["*"] = paste("*") },
92
+ }
93
+ '
94
+ # LazyVim empties clipboard over SSH, then restores that value on VeryLazy.
95
+ # Scheduled, this runs after the restore, so a plain y copies too. It is left
96
+ # out when the user sets clipboard themselves.
97
+ NVIM_UNNAMEDPLUS_LUA=' vim.api.nvim_create_autocmd("User", {
98
+ pattern = "VeryLazy",
99
+ once = true,
100
+ callback = function()
101
+ vim.schedule(function()
102
+ if vim.o.clipboard == "" then
103
+ vim.opt.clipboard = "unnamedplus"
104
+ end
105
+ end)
106
+ end,
107
+ })
108
+ '
109
+
110
+ apply_neovim() {
111
+ # The one component that is off unless asked for. Every other one writes
112
+ # files this setup owns end to end, or a marked region inside a config that
113
+ # invites one. A neovim config is neither: the user built it, so this waits.
114
+ [ "$(cfg_get neovim.enabled false)" = "true" ] || return 0
115
+ # The dot appends to LazyVim's own lualine section, and the clipboard undoes
116
+ # a LazyVim default, so both mean nothing on a config that is not LazyVim.
117
+ # Two markers, because a LazyVim config never opened has no plugins yet.
118
+ if ! grep -q LazyVim "$HOME/.config/nvim/lua/config/lazy.lua" 2>/dev/null \
119
+ && [ ! -d "$HOME/.local/share/nvim/lazy/LazyVim" ]; then
120
+ say "neovim: no LazyVim config on this machine, plugin file not written"
121
+ return 0
122
+ fi
123
+ nvim_ours || return 0
124
+
125
+ local dot_spec="" clip_lua="" mine
126
+ if mine="$(nvim_dot_elsewhere)"; then
127
+ say "neovim: $mine already marks a modified buffer in lualine."
128
+ # nvim_ours has already run, so a file at this path is one we wrote. A user
129
+ # who installed first and wrote their own override later would otherwise
130
+ # keep both, which is the pair of dots this guard exists to prevent.
131
+ if grep -q 'vim\.bo\.modified' "$NVIM_PLUGIN" 2>/dev/null; then
132
+ say " The dot this component wrote was removed, so only one is drawn."
133
+ else
134
+ say " The dot was not written. Two would be drawn side by side."
135
+ fi
136
+ say " Delete $mine to hand the dot back to this component."
137
+ else
138
+ local dot; dot="$(cfg_get neovim.unsaved_dot_color '#f38ba8')"
139
+ dot_spec="$(cat <<H
140
+
141
+ -- LazyVim marks an unsaved buffer by recolouring the filename and nothing
142
+ -- else, because its pretty_path component sets modified_sign = "". A glance
143
+ -- at the statusline then cannot tell a saved buffer from an unsaved one.
144
+ -- This appends a dot to the same section, which can.
145
+ return {
146
+ {
147
+ "nvim-lualine/lualine.nvim",
148
+ opts = function(_, opts)
149
+ table.insert(opts.sections.lualine_c, {
150
+ function()
151
+ return vim.bo.modified and "●" or ""
152
+ end,
153
+ color = { fg = "$dot" },
154
+ })
155
+ end,
156
+ },
157
+ }
158
+ H
159
+ )"
160
+ fi
161
+
162
+ if [ "$(cfg_get neovim.osc52_clipboard true)" = "true" ]; then
163
+ # Whichever lua sets vim.g.clipboard last wins, and the user's own files
164
+ # load after ours, so ours would only add a forced clipboard option.
165
+ if mine="$(nvim_user_sets 'g\.clipboard')"; then
166
+ say "neovim: $mine already sets vim.g.clipboard."
167
+ if grep -q 'vim\.g\.clipboard' "$NVIM_PLUGIN" 2>/dev/null; then
168
+ say " The OSC 52 copy this component wrote was removed."
169
+ else
170
+ say " The OSC 52 copy was not written."
171
+ fi
172
+ else
173
+ clip_lua="$NVIM_CLIPBOARD_LUA"
174
+ if mine="$(nvim_user_sets '[.]o[pt]*[.]clipboard')"; then
175
+ say "neovim: $mine sets clipboard, so y keeps the value set there"
176
+ else
177
+ clip_lua="$clip_lua$NVIM_UNNAMEDPLUS_LUA"
178
+ fi
179
+ clip_lua="${clip_lua}end
180
+ "
181
+ fi
182
+ fi
183
+
184
+ if [ -z "$dot_spec$clip_lua" ]; then
185
+ rm -f "$NVIM_PLUGIN"
186
+ return 0
187
+ fi
188
+ mkdir -p "$(dirname "$NVIM_PLUGIN")"
189
+ {
190
+ printf -- '-- %s. Remove it with: setup.sh disable neovim\n' "$NVIM_MARK"
191
+ printf '%s' "$clip_lua"
192
+ # Every file under lua/plugins must return a spec, even an empty one.
193
+ if [ -n "$dot_spec" ]; then printf '%s\n' "$dot_spec"; else printf '\nreturn {}\n'; fi
194
+ } > "$NVIM_PLUGIN"
195
+ [ -n "$dot_spec" ] && say "neovim: unsaved-changes dot written to $NVIM_PLUGIN"
196
+ [ -n "$clip_lua" ] && say "neovim: OSC 52 copy written to $NVIM_PLUGIN"
197
+ return 0
198
+ }
199
+
200
+ apply_delta() {
201
+ have delta || { say "delta not installed, skipping git pager"; return 0; }
202
+ [ "$(cfg_get delta.set_git_pager true)" = "true" ] || { say "delta: git pager left alone"; return 0; }
203
+ git config --global core.pager delta
204
+ git config --global interactive.diffFilter "delta --color-only"
205
+ git config --global delta.navigate true
206
+ git config --global delta.line-numbers true
207
+ git config --global merge.conflictstyle zdiff3
208
+ say "delta set as git pager"
209
+ }
210
+
211
+ apply_remote() {
212
+ # A VM reached over SSH has no display and no clipboard of its own, so
213
+ # anything that shells out to xclip fails. Put an xclip on PATH that speaks
214
+ # OSC 52 to the terminal at the far end instead.
215
+ [ "$(cfg_get remote.clipboard_shim true)" = "true" ] || {
216
+ say "clipboard: xclip stand-in not installed"; return 0; }
217
+ local real
218
+ # Only when the machine has neither a real xclip nor a display for one to
219
+ # talk to. Shadowing a working clipboard would be a downgrade. A stand-in
220
+ # this script wrote before is not a real one, wherever it turns up.
221
+ real="$(command -v xclip 2>/dev/null)"
222
+ if [ -n "$real" ] && ! grep -q "Installed by acstack" "$real" 2>/dev/null; then
223
+ say "clipboard: real xclip at $real, stand-in not installed"
224
+ return 0
225
+ fi
226
+ if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then
227
+ say "clipboard: this machine has a display, stand-in not installed"
228
+ return 0
229
+ fi
230
+ cp "$SCRIPT_DIR/helpers/xclip" "$BIN/xclip"
231
+ chmod +x "$BIN/xclip"
232
+ say "clipboard: xclip stand-in written, copying over OSC 52"
233
+ }
234
+
235
+ apply_harlequin() {
236
+ # uv is astral's python installer. harlequin is a python program, so there is
237
+ # no release tarball to unpack: uv builds it an environment of its own and
238
+ # links one command into ~/.local/bin. Nothing else here needs uv, so a
239
+ # machine without it loses this component and keeps the rest.
240
+ have uv || {
241
+ say "uv not installed, skipping harlequin"
242
+ say "install it with: curl -LsSf https://astral.sh/uv/install.sh | sh"
243
+ return 0
244
+ }
245
+ local a adapters missing=0
246
+ adapters=$(cfg_get_list harlequin.adapters "postgres snowflake")
247
+ local -a with=()
248
+ for a in $adapters; do with+=(--with "harlequin-$a"); done
249
+
250
+ # uv records what it installed in a receipt beside the environment.
251
+ # `uv tool install` on an already-installed tool does nothing at all, so an
252
+ # adapter added to the config would never arrive without --force. Compare the
253
+ # receipt against the config and force only when they differ.
254
+ local receipt="${UV_TOOL_DIR:-$HOME/.local/share/uv/tools}/harlequin/uv-receipt.toml"
255
+ if have harlequin && [ -f "$receipt" ]; then
256
+ for a in $adapters; do
257
+ grep -q "name = \"harlequin-$a\"" "$receipt" || missing=1
258
+ done
259
+ [ "$missing" = "0" ] && { say "harlequin already installed"; return 0; }
260
+ say "harlequin: adapters changed, reinstalling"
261
+ uv tool install --force harlequin "${with[@]}" >/dev/null 2>&1 \
262
+ && say "harlequin reinstalled with: $(echo $adapters)" \
263
+ || say "harlequin reinstall failed, run: uv tool install --force harlequin"
264
+ return 0
265
+ fi
266
+ uv tool install harlequin "${with[@]}" >/dev/null 2>&1 \
267
+ && say "harlequin installed with: $(echo $adapters)" \
268
+ || say "harlequin install failed, run: uv tool install harlequin"
269
+ }
270
+
271
+ apply_lens_extra_tools() {
272
+ # A small machine-wide pi-lens toolbelt for common project ecosystems.
273
+ # Pi-lens can fall back to npx or prompt for some tools, but putting these
274
+ # on PATH makes them available in any repo without per-project setup.
275
+ if [ "$(cfg_get pi.lens_extra_tools.python false)" = "true" ]; then
276
+ install_uv_tool black black
277
+ install_uv_tool mypy mypy
278
+ install_npm_tool pyright pyright
279
+ fi
280
+ if [ "$(cfg_get pi.lens_extra_tools.docker false)" = "true" ]; then
281
+ install_plain_github_binary hadolint hadolint/hadolint Linux-x86_64
282
+ install_npm_tool docker-langserver dockerfile-language-server-nodejs
283
+ fi
284
+ if [ "$(cfg_get pi.lens_extra_tools.github_actions false)" = "true" ]; then
285
+ install_binary actionlint rhysd/actionlint linux_amd64
286
+ install_uv_tool zizmor zizmor
287
+ fi
288
+ if [ "$(cfg_get pi.lens_extra_tools.terraform false)" = "true" ]; then
289
+ install_binary terraform-ls hashicorp/terraform-ls linux_amd64
290
+ install_binary tflint terraform-linters/tflint linux_amd64
291
+ install_plain_github_binary terragrunt gruntwork-io/terragrunt linux_amd64
292
+ fi
293
+ }