@vibelord/lanes 1.1.1 → 1.2.0
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/README.md +36 -5
- package/bin/lanes-install.mjs +166 -13
- package/lib/plan.mjs +113 -3
- package/package.json +1 -1
- package/payload/lanes/README.md +45 -22
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +2459 -164
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
- package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
- package/payload/lanes/client/lanes-launch +289 -40
- package/payload/lanes/client/lanes-window +15 -3
- package/payload/lanes/container/hooks/claude-settings.json +1 -0
- package/payload/lanes/container/lane +106 -92
- package/payload/lanes/container/lane-edit +100 -0
- package/payload/lanes/container/lane-mcp +1091 -0
- package/payload/lanes/container/lane-signal +109 -29
- package/payload/lanes/container/lane_core.py +1140 -0
- package/payload/lanes/container/vimrc +31 -0
- package/payload/lanes/docs/ARCHITECTURE.md +618 -13
- package/payload/lanes/docs/DESIGN.md +122 -44
- package/payload/lanes/docs/INSTALL.md +157 -17
- package/payload/lanes/docs/RUNBOOK.md +603 -72
- package/payload/lanes/docs/SHARING.md +3 -2
- package/payload/lanes/docs/USERGUIDE.md +777 -136
- package/payload/lanes/install/enable-web.sh +2 -0
- package/payload/lanes/install/host-install.sh +163 -7
- package/payload/lanes/install/services.py +148 -18
- package/payload/lanes/install.sh +68 -7
- package/payload/lanes/libexec/agent_state.py +74 -1
- package/payload/lanes/libexec/cursor_sync.py +6 -0
- package/payload/lanes/libexec/device_sync.py +47 -10
- package/payload/lanes/libexec/economy_poll.py +1 -0
- package/payload/lanes/libexec/enrold.py +41 -22
- package/payload/lanes/libexec/keys.py +54 -23
- package/payload/lanes/libexec/lanes_lib.py +620 -22
- package/payload/lanes/libexec/members.py +16 -2
- package/payload/lanes/libexec/notify.py +104 -24
- package/payload/lanes/libexec/registrations.py +216 -18
- package/payload/lanes/libexec/session_actions.py +182 -14
- package/payload/lanes/libexec/sidebar +11 -3
- package/payload/lanes/libexec/sidebar.py +2135 -361
- package/payload/lanes/libexec/statusd.py +137 -10
- package/payload/lanes/libexec/studio_host.py +8 -1
- package/payload/lanes/libexec/tokens.py +121 -30
- package/payload/lanes/libexec/tokens.sh +86 -2
- package/payload/lanes/libexec/update.py +972 -0
- package/payload/lanes/libexec/users.py +614 -0
- package/payload/lanes/libexec/view.sh +34 -6
- package/payload/lanes/systemd/lanes-ensure.service +3 -0
- package/payload/lanes/systemd/lanes-gc.service +1 -1
- package/payload/lanes/systemd/lanes-update.service +17 -0
- package/payload/lanes/systemd/lanes-update.timer +17 -0
- package/payload/lanes/web/README.md +83 -24
- package/payload/lanes/web/browser.js +3 -5
- package/payload/lanes/web/mobile.css +17 -17
- package/payload/lanes/web/mobile.js +44 -32
- package/payload/lanes/workspaces.example/README.md +3 -1
- package/payload/lanes/zellij/config.kdl +106 -56
- package/payload/manifest.json +5 -3
|
@@ -11,7 +11,9 @@ a `holder: tmux` lane through its exact socket and session; any other id is a zm
|
|
|
11
11
|
lane; if it does not exist, run its recorded create recipe. Never nests inside tmux.
|
|
12
12
|
While the lanes view shows the lane, attach with ignore-size (the lanes view owns the size).
|
|
13
13
|
lane attach <id> attach this terminal (never creates; a tmux lane never nests inside tmux)
|
|
14
|
-
lane send <id> [--no-enter] <text...> type into the lane. tmux: literally, then Enter unless --no-enter
|
|
14
|
+
lane send <id> [--no-enter] <text...> type into the lane. tmux: literally, then Enter unless --no-enter
|
|
15
|
+
(a second tmux client half a second later: an agent's prompt takes text+Enter in one
|
|
16
|
+
burst as a paste)
|
|
15
17
|
zmx: the bytes as given, never an Enter of its own (use $'\\r')
|
|
16
18
|
lane capture <id> [--lines N] [--vt] print scrollback (plain text, or VT with --vt)
|
|
17
19
|
lane ls [--json] [--all] zmx lanes with holder metadata; --all adds the registry's tmux lanes with `alive`
|
|
@@ -21,6 +23,16 @@ a `holder: tmux` lane through its exact socket and session; any other id is a zm
|
|
|
21
23
|
hook sidecar says the agent is `working` unless --force
|
|
22
24
|
lane gc --sockets unlink this container's tmux socket files that tmux is refused on (ECONNREFUSED: no
|
|
23
25
|
server listens); prints each unlinked path. A live, empty or silent server keeps its file
|
|
26
|
+
lane mcp install [--agent claude|codex|all] [--write]
|
|
27
|
+
add the Lanes MCP server (~/.lz/bin/lane-mcp) to ~/.claude.json (mcpServers.lanes) and
|
|
28
|
+
~/.codex/config.toml ([mcp_servers.lanes], with env_vars passing LANE_ID, LANE_WS, TMUX
|
|
29
|
+
and TMUX_PANE: Codex clears a server's environment): prints the diff; --write applies
|
|
30
|
+
it, keeping <file>.bak-<UTC> first; an entry that is already there is left alone (a
|
|
31
|
+
Codex table missing those env_vars gains them), one named `lanes` that starts something
|
|
32
|
+
else is never replaced (exit 1). New agent sessions load it
|
|
33
|
+
|
|
34
|
+
Every tmux client these verbs start runs with -N (never starts a server), without a lane identity in its
|
|
35
|
+
environment and bounded in time (lane_core.py, shipped beside this file).
|
|
24
36
|
"""
|
|
25
37
|
import json
|
|
26
38
|
import os
|
|
@@ -34,6 +46,10 @@ import sys
|
|
|
34
46
|
import time
|
|
35
47
|
from pathlib import Path
|
|
36
48
|
|
|
49
|
+
sys.dont_write_bytecode = True # ~/.lz/bin holds no __pycache__
|
|
50
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
51
|
+
import lane_core as C # noqa: E402 (shipped beside this file by `lanes install-container`)
|
|
52
|
+
|
|
37
53
|
ROOT = Path(os.environ.get('LANE_ROOT', '/home/coder/.lz'))
|
|
38
54
|
VERSION = os.environ.get('LANE_ZMX_VERSION', '0.8.1')
|
|
39
55
|
ZMX = ROOT / 'bin' / f'zmx-{VERSION}'
|
|
@@ -41,8 +57,9 @@ SOCKETS, META, STATE = ROOT / 's', ROOT / 'meta', ROOT / 'state'
|
|
|
41
57
|
HOOKS = STATE / 'hooks' # lane-signal's sidecars (never lane-run's phase records one directory up, DEF-1572)
|
|
42
58
|
PROC = Path('/proc') # this container's pid namespace: the hook's agent pid is checked here
|
|
43
59
|
IDENT = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$')
|
|
44
|
-
STATUSES =
|
|
45
|
-
HOOK_MAX_AGE =
|
|
60
|
+
STATUSES = C.STATUSES # agent_state.STATUSES
|
|
61
|
+
HOOK_MAX_AGE = C.HOOK_MAX_AGE
|
|
62
|
+
TMUX_TIMEOUT = 15
|
|
46
63
|
|
|
47
64
|
|
|
48
65
|
def zmx_env(extra=None):
|
|
@@ -161,49 +178,32 @@ def registry_entry(lane):
|
|
|
161
178
|
return lanes[lane]
|
|
162
179
|
|
|
163
180
|
|
|
164
|
-
IDENTITY =
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
that socket created without its own `-e` inherits it (MEASURED, tmux 3.4: DEF-1559). Board sockets are shared by
|
|
171
|
-
many lanes, so a lane identity in the env of anything that may start one would be stamped on unrelated lanes.
|
|
172
|
-
Identity therefore reaches a lane's shell ONLY through `-e` in its create recipe (lanes_lib.canonical_recipe).
|
|
173
|
-
An operator's own ANTHROPIC_CUSTOM_HEADERS that is not a lane trace is theirs and stays."""
|
|
174
|
-
return {k: v for k, v in environ.items() if k not in drop and k not in IDENTITY
|
|
175
|
-
and not (k == 'ANTHROPIC_CUSTOM_HEADERS' and v.startswith('x-throttle-trace: lanes/'))}
|
|
181
|
+
IDENTITY = C.IDENTITY # + a lanes/ ANTHROPIC_CUSTOM_HEADERS (identity_free)
|
|
182
|
+
# `environ` without any lane identity: whatever tmux client STARTS a socket's server hands its whole environment to
|
|
183
|
+
# the server's GLOBAL environment (MEASURED, tmux 3.4: DEF-1559), so identity reaches a lane's shell ONLY through `-e`
|
|
184
|
+
# in its create recipe (lanes_lib.canonical_recipe). lane_core.identity_free keeps an operator's own header.
|
|
185
|
+
identity_free = C.identity_free
|
|
186
|
+
tmux_target = C.tmux_target # (socket, session) of a registry entry held by tmux, else None
|
|
176
187
|
|
|
177
188
|
|
|
178
189
|
def tmux_run(argv):
|
|
179
|
-
"""One tmux client
|
|
180
|
-
|
|
190
|
+
"""One tmux client (lane_core.run_tmux): no lane identity in its env, stdin closed, exit 124 after
|
|
191
|
+
TMUX_TIMEOUT s without an answer."""
|
|
192
|
+
return C.run_tmux(argv, timeout=TMUX_TIMEOUT)
|
|
181
193
|
|
|
182
194
|
|
|
183
195
|
def tmux_cmd(socket, *args):
|
|
184
|
-
|
|
196
|
+
"""`tmux -N -L <socket> ARGS…`: -N, so no query or attach ever starts a server (DEF-1797)."""
|
|
197
|
+
return tmux_run(C.tmux_argv(socket, *args))
|
|
185
198
|
|
|
186
199
|
|
|
187
200
|
def registry_lanes(verb):
|
|
188
201
|
"""{lane id: entry} of ~/.lz/registry.json; {} when there is none (a zmx-only container needs none). A newer
|
|
189
202
|
registry is refused, as `lane open` refuses it."""
|
|
190
203
|
try:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
except (OSError, ValueError, KeyError, TypeError):
|
|
194
|
-
return {}
|
|
195
|
-
if int(data.get('version', 1)) > 1:
|
|
204
|
+
return C.registry_lanes(ROOT)
|
|
205
|
+
except C.NewerVersion:
|
|
196
206
|
sys.exit(f'lane {verb}: the registry is newer than this `lane` — on the host run: lanes install-container <container>')
|
|
197
|
-
return lanes if isinstance(lanes, dict) else {}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
def tmux_target(entry):
|
|
201
|
-
"""(socket, session) of a registry entry held by tmux, else None."""
|
|
202
|
-
if not isinstance(entry, dict) or entry.get('holder') != 'tmux':
|
|
203
|
-
return None
|
|
204
|
-
tmux = entry.get('tmux') or {}
|
|
205
|
-
socket, session = tmux.get('socket'), tmux.get('session')
|
|
206
|
-
return (str(socket), str(session)) if socket and session else None
|
|
207
207
|
|
|
208
208
|
|
|
209
209
|
def tmux_lane(verb, lane):
|
|
@@ -220,33 +220,20 @@ def zmx_name(verb, lane):
|
|
|
220
220
|
return name if isinstance(name, str) and IDENT.match(name) else lane
|
|
221
221
|
|
|
222
222
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
def send_argv(socket, session, text, enter=True):
|
|
231
|
-
"""Type `text` literally into the session's active pane, then Enter — one tmux client, `;`-separated. The target
|
|
232
|
-
is `=<session>:` (exact session, its current window): a bare `=<session>` is refused by send-keys and
|
|
233
|
-
capture-pane with "can't find pane" (MEASURED, tmux 3.4)."""
|
|
234
|
-
target = f'={session}:'
|
|
235
|
-
argv = ['tmux', '-L', socket, 'send-keys', '-t', target, '-l', '--', tmux_text(text)]
|
|
236
|
-
return argv + [';', 'send-keys', '-t', target, 'Enter'] if enter else argv
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def capture_argv(socket, session, lines, vt=False):
|
|
240
|
-
"""Joined scrollback of the session's active pane: the last `lines` history lines plus the screen (`-S -N`; the
|
|
241
|
-
caller keeps the last N non-blank lines), or all of it (`-S -`); `--vt` keeps the escape sequences (`-e`)."""
|
|
242
|
-
argv = ['tmux', '-L', socket, 'capture-pane', '-p', '-J', '-S', f'-{lines}' if lines else '-', '-t', f'={session}:']
|
|
243
|
-
return argv + ['-e'] if vt else argv
|
|
223
|
+
# tmux's argv parser ends a command at ONE trailing `;` of ANY argument: the last `;` is written `\\;`. The target is
|
|
224
|
+
# `=<session>:` (exact session, its current window): a bare `=<session>` is refused by send-keys and capture-pane
|
|
225
|
+
# with "can't find pane" (MEASURED, tmux 3.4). Every argv carries -N. A text and its Enter are two clients
|
|
226
|
+
# ENTER_DELAY s apart (lane_core.send_keys): in one client they reach the pane as one read, which an agent's prompt
|
|
227
|
+
# takes as a paste (DEF-1803).
|
|
228
|
+
tmux_text, send_argvs, capture_argv = C.tmux_text, C.send_argvs, C.capture_argv
|
|
244
229
|
|
|
245
230
|
|
|
246
231
|
def tmux_attach_argv(socket, session):
|
|
247
|
-
"""attach-session; with ignore-size while the lanes view shows the lane (the lanes view owns the size).
|
|
232
|
+
"""attach-session; with ignore-size while the lanes view shows the lane (the lanes view owns the size). -N: when
|
|
233
|
+
the session's server ended after the has-session check, the attach fails instead of starting an empty server
|
|
234
|
+
that leaves a socket file behind (MEASURED, tmux 3.4: DEF-1797)."""
|
|
248
235
|
clients = tmux_cmd(socket, 'list-clients', '-t', f'={session}', '-F', '#{client_termname}').stdout.split()
|
|
249
|
-
argv =
|
|
236
|
+
argv = C.tmux_argv(socket, 'attach-session')
|
|
250
237
|
if VIEWER_TERM in clients:
|
|
251
238
|
argv += ['-f', 'ignore-size'] # the lanes view is showing this lane: never resize it from here
|
|
252
239
|
return argv + ['-t', f'={session}']
|
|
@@ -254,34 +241,8 @@ def tmux_attach_argv(socket, session):
|
|
|
254
241
|
|
|
255
242
|
def hook_agent(name):
|
|
256
243
|
"""The lane's `lane-signal` sidecar (~/.lz/state/hooks/<name>.json) as an agent dict, or {} — judged as the host's
|
|
257
|
-
`agent_state.read_sidecar_dict` judges it
|
|
258
|
-
|
|
259
|
-
writer recorded its `start`). A crashed agent's last word is never an agent."""
|
|
260
|
-
try:
|
|
261
|
-
d = json.loads((HOOKS / f'{name}.json').read_text())
|
|
262
|
-
except (OSError, ValueError):
|
|
263
|
-
return {}
|
|
264
|
-
if not isinstance(d, dict) or d.get('v') != 1 or d.get('status') not in STATUSES:
|
|
265
|
-
return {}
|
|
266
|
-
try:
|
|
267
|
-
since = int(d.get('since') or 0)
|
|
268
|
-
at = int(d.get('at') or since)
|
|
269
|
-
pid = int(d.get('pid') or 0)
|
|
270
|
-
except (TypeError, ValueError):
|
|
271
|
-
return {}
|
|
272
|
-
if since <= 0 or time.time() - at > HOOK_MAX_AGE:
|
|
273
|
-
return {}
|
|
274
|
-
if pid > 0:
|
|
275
|
-
try:
|
|
276
|
-
stat = (PROC / str(pid) / 'stat').read_text()
|
|
277
|
-
except OSError:
|
|
278
|
-
return {}
|
|
279
|
-
if d.get('start') is not None:
|
|
280
|
-
fields = stat.rsplit(')', 1)[-1].split()
|
|
281
|
-
if len(fields) < 20 or fields[19] != str(d['start']):
|
|
282
|
-
return {}
|
|
283
|
-
return {'harness': str(d.get('harness') or ''), 'status': d['status'], 'since': since, 'at': at,
|
|
284
|
-
'event': str(d.get('event') or ''), 'session': str(d.get('session') or ''), 'pid': pid, 'source': 'hook'}
|
|
244
|
+
`agent_state.read_sidecar_dict` judges it (lane_core.hook_agent). A crashed agent's last word is never an agent."""
|
|
245
|
+
return C.hook_agent(HOOKS, name, PROC)
|
|
285
246
|
|
|
286
247
|
|
|
287
248
|
def cmd_open(args):
|
|
@@ -343,19 +304,19 @@ def cmd_send(args):
|
|
|
343
304
|
held = tmux_lane('send', name)
|
|
344
305
|
if held:
|
|
345
306
|
_, socket, session = held
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
307
|
+
results = C.send_keys(socket, session, text, enter, run=tmux_run)
|
|
308
|
+
sys.stderr.write(''.join(result.stderr for result in results))
|
|
309
|
+
if len(results) == 2 and results[1].returncode:
|
|
310
|
+
sys.stderr.write('lane send: the text was typed; Enter was not pressed\n')
|
|
311
|
+
sys.exit(results[-1].returncode)
|
|
312
|
+
result = zmx('send', zmx_name('send', name), text) # zmx: the bytes as given, never an Enter of its own
|
|
349
313
|
sys.stderr.write(result.stderr)
|
|
350
314
|
sys.exit(result.returncode)
|
|
351
315
|
|
|
352
316
|
|
|
353
317
|
def last_lines(text, lines):
|
|
354
318
|
"""The last `lines` lines that end in something: a tmux capture ends with the screen's unused blank rows."""
|
|
355
|
-
|
|
356
|
-
while rows and not rows[-1].strip():
|
|
357
|
-
rows.pop()
|
|
358
|
-
return '\n'.join(rows[-lines:]) + '\n'
|
|
319
|
+
return C.last_lines(text, lines) or '\n'
|
|
359
320
|
|
|
360
321
|
|
|
361
322
|
def cmd_capture(args):
|
|
@@ -560,8 +521,61 @@ def cmd_gc(args):
|
|
|
560
521
|
print(f'lane gc --sockets: {removed} unlinked, {kept} kept in {directory}')
|
|
561
522
|
|
|
562
523
|
|
|
524
|
+
MCP_USAGE = 'usage: lane mcp install [--agent claude|codex|all] [--write]'
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def cmd_mcp(args):
|
|
528
|
+
"""`lane mcp install`: the agents' MCP entries for lane-mcp in THIS home (lane_core.mcp_plan / mcp_apply — the
|
|
529
|
+
code `lanes install-container` runs from the host). A dry run (the diffs) unless --write. Exit 1 when a file was
|
|
530
|
+
left alone (not valid JSON/TOML, or a `lanes` entry that starts something else) or lane-mcp is not installed."""
|
|
531
|
+
if not args or args[0] != 'install':
|
|
532
|
+
sys.exit(MCP_USAGE)
|
|
533
|
+
agents, write, rest = C.MCP_AGENTS, False, list(args[1:])
|
|
534
|
+
while rest:
|
|
535
|
+
word = rest.pop(0)
|
|
536
|
+
if word == '--agent' and rest and rest[0] in ('claude', 'codex', 'all'):
|
|
537
|
+
pick = rest.pop(0)
|
|
538
|
+
agents = C.MCP_AGENTS if pick == 'all' else (pick,)
|
|
539
|
+
elif word == '--write':
|
|
540
|
+
write = True
|
|
541
|
+
else:
|
|
542
|
+
sys.exit(MCP_USAGE)
|
|
543
|
+
home = Path.home()
|
|
544
|
+
binary = ROOT / 'bin' / 'lane-mcp'
|
|
545
|
+
missing = not os.access(str(binary), os.X_OK)
|
|
546
|
+
if missing:
|
|
547
|
+
print(f'! {binary} is not installed — on the host run: lanes install-container <container>')
|
|
548
|
+
refused = False
|
|
549
|
+
for item in C.mcp_plan(home, agents):
|
|
550
|
+
if item['why']:
|
|
551
|
+
refused = True
|
|
552
|
+
print(f"! {item['path']}: {item['why']}")
|
|
553
|
+
elif item['after'] == item['before']:
|
|
554
|
+
print(f"{item['path']}: the lanes MCP server is already there")
|
|
555
|
+
else:
|
|
556
|
+
sys.stdout.write(C.mcp_diff(item))
|
|
557
|
+
if not write:
|
|
558
|
+
print('dry run: nothing written (add --write to apply)')
|
|
559
|
+
sys.exit(1 if refused or missing else 0)
|
|
560
|
+
if missing:
|
|
561
|
+
sys.exit('lane mcp install: nothing written — an entry would start a lane-mcp that is not there')
|
|
562
|
+
added = False
|
|
563
|
+
for result in C.mcp_apply(home, agents):
|
|
564
|
+
if result['status'] == 'refused':
|
|
565
|
+
refused = True
|
|
566
|
+
print(f"! {result['path']}: {result['why']} — nothing written")
|
|
567
|
+
elif result['status'] == 'added':
|
|
568
|
+
added = True
|
|
569
|
+
if result['backup']:
|
|
570
|
+
print(f"backup {result['backup']}")
|
|
571
|
+
print(f"wrote {result['path']}")
|
|
572
|
+
if added:
|
|
573
|
+
print('new Claude Code and Codex sessions load it (a running session keeps its tools)')
|
|
574
|
+
sys.exit(1 if refused else 0)
|
|
575
|
+
|
|
576
|
+
|
|
563
577
|
COMMANDS = {'new': cmd_new, 'open': cmd_open, 'attach': cmd_attach, 'send': cmd_send, 'capture': cmd_capture, 'ls': cmd_ls,
|
|
564
|
-
'status': cmd_status, 'kill': cmd_kill, 'gc': cmd_gc}
|
|
578
|
+
'status': cmd_status, 'kill': cmd_kill, 'gc': cmd_gc, 'mcp': cmd_mcp}
|
|
565
579
|
|
|
566
580
|
if __name__ == '__main__':
|
|
567
581
|
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# lane-edit — the editor Ctrl-Alt-e opens in a workspace container (W9 E3, LNS-97). Installed at ~/.lz/bin/lane-edit
|
|
3
|
+
# by `lanes install-container`, next to ~/.lz/vimrc. `lanes edit --focused` (W9 E2, bin/lanes edit_argv) picks the
|
|
4
|
+
# lane's file on the host and starts this with no shell in between:
|
|
5
|
+
# docker exec -it -u coder -w <checkout root> -e TERM=xterm-256color -e COLORTERM=truecolor -e LANG=C.UTF-8
|
|
6
|
+
# -e LANES_EDITOR=<choice or empty> -e LANES_EDIT_LABEL=<label> <container> /home/coder/.lz/bin/lane-edit <path>
|
|
7
|
+
# usage: lane-edit <file>
|
|
8
|
+
#
|
|
9
|
+
# * umask 077 first, so anything the editor creates (a swap file, a backup copy) is this account's alone. A file
|
|
10
|
+
# written in place keeps its own mode and owner.
|
|
11
|
+
# * The editor: $LANES_EDITOR when it names a program on PATH — one word, a name or an absolute path, never a
|
|
12
|
+
# command line: no arguments, no shell — else the first of micro hx nvim vim vi on PATH. Lanes installs no
|
|
13
|
+
# editor; vi is the floor. A $LANES_EDITOR that names nothing is said on stderr, and the list decides.
|
|
14
|
+
# * vim and nvim — by name, or a vi that is vim behind a link (Debian's alternatives) — start with
|
|
15
|
+
# `-u ~/.lz/vimrc` when it is there: written in place, swap and backup files under ~/.lz/tmp/ (created here,
|
|
16
|
+
# 0700) instead of beside the file in the checkout, no modelines, no added final newline, no viminfo.
|
|
17
|
+
# * One header line, `editing <label> · <directory> · <how to finish>` ($LANES_EDIT_LABEL, else the file's name;
|
|
18
|
+
# control characters dropped), then the editor replaces this shell: its exit status is lane-edit's, and
|
|
19
|
+
# quitting it closes the floating pane.
|
|
20
|
+
# * The file reaches the editor as one argv word; one that starts with `-` goes as ./<name>, never as an option.
|
|
21
|
+
# Exit: the editor's status; 2 usage; 127 no editor on PATH. Both refusals wait for Enter on a terminal first, as
|
|
22
|
+
# `lanes edit` does on the host: the pane (close_on_exit) would take the message with it.
|
|
23
|
+
umask 077
|
|
24
|
+
set -u
|
|
25
|
+
|
|
26
|
+
hold() { # the Edit pane closes the moment this exits: on a terminal, let a refusal be read first
|
|
27
|
+
if [ -t 0 ]; then
|
|
28
|
+
printf '\nPress Enter to close.' >&2
|
|
29
|
+
read -r _ || :
|
|
30
|
+
fi
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if [ $# -ne 1 ] || [ -z "$1" ]; then
|
|
34
|
+
echo 'usage: lane-edit <file>' >&2
|
|
35
|
+
hold
|
|
36
|
+
exit 2
|
|
37
|
+
fi
|
|
38
|
+
file=$1
|
|
39
|
+
case $file in -*) file=./$file ;; esac
|
|
40
|
+
root=${HOME:-/home/coder}/.lz
|
|
41
|
+
candidates='micro hx nvim vim vi'
|
|
42
|
+
editor='' note='' vim=''
|
|
43
|
+
|
|
44
|
+
clean() { # $1 without control characters: a label or a directory never moves the cursor or retitles the terminal
|
|
45
|
+
printf '%s' "$1" | tr -d '\000-\037\177'
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
program() { # the absolute path of the program $1 names on PATH; nothing for a builtin, function, folder or plain file
|
|
49
|
+
found=$(command -v -- "$1" 2>/dev/null) || return 1
|
|
50
|
+
case $found in
|
|
51
|
+
/*) [ -f "$found" ] && [ -x "$found" ] && printf '%s' "$found" ;;
|
|
52
|
+
*) return 1 ;;
|
|
53
|
+
esac
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
vim_named() { # whether the basename of $1 is vim or nvim (vim.basic, vim.tiny, nvim.appimage too)
|
|
57
|
+
case ${1##*/} in vim | vim.* | nvim | nvim.*) return 0 ;; esac
|
|
58
|
+
return 1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if [ -n "${LANES_EDITOR:-}" ]; then
|
|
62
|
+
editor=$(program "$LANES_EDITOR") || editor=
|
|
63
|
+
if [ -z "$editor" ]; then
|
|
64
|
+
note="lane-edit: LANES_EDITOR=$(clean "$LANES_EDITOR") is not a program on PATH"
|
|
65
|
+
fi
|
|
66
|
+
fi
|
|
67
|
+
if [ -z "$editor" ]; then
|
|
68
|
+
for name in $candidates; do
|
|
69
|
+
editor=$(program "$name") && break
|
|
70
|
+
editor=
|
|
71
|
+
done
|
|
72
|
+
if [ -n "$note" ]; then
|
|
73
|
+
if [ -n "$editor" ]; then echo "$note; using ${editor##*/}" >&2; else echo "$note" >&2; fi
|
|
74
|
+
fi
|
|
75
|
+
fi
|
|
76
|
+
if [ -z "$editor" ]; then
|
|
77
|
+
echo "lane-edit: no editor on PATH: set LANES_EDITOR, or install one of $candidates in this container" >&2
|
|
78
|
+
hold
|
|
79
|
+
exit 127
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
if vim_named "$editor" || vim_named "$(readlink -f "$editor" 2>/dev/null)"; then
|
|
83
|
+
vim=1
|
|
84
|
+
fi
|
|
85
|
+
case ${editor##*/} in
|
|
86
|
+
micro) finish='Ctrl-s, Ctrl-q to finish' ;;
|
|
87
|
+
vi | hx) finish=':wq to finish' ;;
|
|
88
|
+
*) if [ -n "$vim" ]; then finish=':wq to finish'; else finish='save and quit to finish'; fi ;;
|
|
89
|
+
esac
|
|
90
|
+
|
|
91
|
+
label=${LANES_EDIT_LABEL:-}
|
|
92
|
+
[ -n "$label" ] || label=${1##*/}
|
|
93
|
+
printf 'editing %s · %s · %s\n' "$(clean "$label")" "$(clean "$PWD")" "$finish"
|
|
94
|
+
|
|
95
|
+
if [ -n "$vim" ] && [ -r "$root/vimrc" ]; then
|
|
96
|
+
mkdir -p "$root/tmp" 2>/dev/null
|
|
97
|
+
chmod 700 "$root/tmp" 2>/dev/null
|
|
98
|
+
exec "$editor" -u "$root/vimrc" "$file"
|
|
99
|
+
fi
|
|
100
|
+
exec "$editor" "$file"
|