acstack 0.48.5 → 0.49.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/assets/terminal-workspace/helpers/acstack-bridge +185 -0
- package/assets/terminal-workspace/helpers/acstack-clip +88 -0
- package/assets/terminal-workspace/helpers/acstack-dash +25 -0
- package/assets/terminal-workspace/helpers/acstack-db +86 -0
- package/assets/terminal-workspace/helpers/acstack-diff +268 -0
- package/assets/terminal-workspace/helpers/acstack-edit +25 -0
- package/assets/terminal-workspace/helpers/acstack-gh +47 -0
- package/assets/terminal-workspace/helpers/acstack-herdr-renumber +93 -0
- package/assets/terminal-workspace/helpers/acstack-herdr-tab +49 -0
- package/assets/terminal-workspace/helpers/acstack-md +401 -0
- package/assets/terminal-workspace/helpers/acstack-md-lib +219 -0
- package/assets/terminal-workspace/helpers/acstack-open +217 -0
- package/assets/terminal-workspace/helpers/acstack-popup +13 -0
- package/assets/terminal-workspace/helpers/acstack-view +33 -0
- package/assets/terminal-workspace/helpers/xclip +31 -0
- package/assets/terminal-workspace/lib/apply-ghdash.sh +78 -0
- package/assets/terminal-workspace/lib/apply-herdr-numbers.sh +49 -0
- package/assets/terminal-workspace/lib/apply-herdr.sh +308 -0
- package/assets/terminal-workspace/lib/apply-tools.sh +293 -0
- package/assets/terminal-workspace/lib/apply-tuicr.sh +133 -0
- package/assets/terminal-workspace/lib/apply-yazi.sh +389 -0
- package/assets/terminal-workspace/lib/commands.sh +296 -0
- package/assets/terminal-workspace/lib/core.sh +184 -0
- package/assets/terminal-workspace/lib/helper-install.sh +35 -0
- package/assets/terminal-workspace/lib/pi-packages.sh +331 -0
- package/assets/terminal-workspace/lib/pi-state.sh +479 -0
- package/assets/terminal-workspace/lib/versions.sh +254 -0
- package/assets/terminal-workspace/setup.sh +24 -4637
- package/cli/dist/index.js +8 -4
- package/cli/dist/index.js.map +2 -2
- package/cli/package.json +1 -1
- package/package.json +1 -1
- package/skills/archive/SKILL.md +3 -0
- package/skills/grilling/SKILL.md +2 -1
- package/skills/handoff/SKILL.md +14 -14
- package/skills/handoff/launch-herdr.md +15 -4
- package/skills/implement/SKILL.md +19 -3
- package/skills/map/SKILL.md +24 -24
- package/skills/map/alignment.md +38 -0
|
@@ -0,0 +1,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
|
+
}
|