@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.
Files changed (59) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +45 -22
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2459 -164
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +106 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1091 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1140 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +618 -13
  20. package/payload/lanes/docs/DESIGN.md +122 -44
  21. package/payload/lanes/docs/INSTALL.md +157 -17
  22. package/payload/lanes/docs/RUNBOOK.md +603 -72
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +777 -136
  25. package/payload/lanes/install/enable-web.sh +2 -0
  26. package/payload/lanes/install/host-install.sh +163 -7
  27. package/payload/lanes/install/services.py +148 -18
  28. package/payload/lanes/install.sh +68 -7
  29. package/payload/lanes/libexec/agent_state.py +74 -1
  30. package/payload/lanes/libexec/cursor_sync.py +6 -0
  31. package/payload/lanes/libexec/device_sync.py +47 -10
  32. package/payload/lanes/libexec/economy_poll.py +1 -0
  33. package/payload/lanes/libexec/enrold.py +41 -22
  34. package/payload/lanes/libexec/keys.py +54 -23
  35. package/payload/lanes/libexec/lanes_lib.py +620 -22
  36. package/payload/lanes/libexec/members.py +16 -2
  37. package/payload/lanes/libexec/notify.py +104 -24
  38. package/payload/lanes/libexec/registrations.py +216 -18
  39. package/payload/lanes/libexec/session_actions.py +182 -14
  40. package/payload/lanes/libexec/sidebar +11 -3
  41. package/payload/lanes/libexec/sidebar.py +2135 -361
  42. package/payload/lanes/libexec/statusd.py +137 -10
  43. package/payload/lanes/libexec/studio_host.py +8 -1
  44. package/payload/lanes/libexec/tokens.py +121 -30
  45. package/payload/lanes/libexec/tokens.sh +86 -2
  46. package/payload/lanes/libexec/update.py +972 -0
  47. package/payload/lanes/libexec/users.py +614 -0
  48. package/payload/lanes/libexec/view.sh +34 -6
  49. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  50. package/payload/lanes/systemd/lanes-gc.service +1 -1
  51. package/payload/lanes/systemd/lanes-update.service +17 -0
  52. package/payload/lanes/systemd/lanes-update.timer +17 -0
  53. package/payload/lanes/web/README.md +83 -24
  54. package/payload/lanes/web/browser.js +3 -5
  55. package/payload/lanes/web/mobile.css +17 -17
  56. package/payload/lanes/web/mobile.js +44 -32
  57. package/payload/lanes/workspaces.example/README.md +3 -1
  58. package/payload/lanes/zellij/config.kdl +106 -56
  59. 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 = ('working', 'needs_input', 'needs_approval', 'done', 'errored', 'shell', 'idle') # agent_state.STATUSES
45
- HOOK_MAX_AGE = 3600 # statusd.SIDECAR_MAX_AGE: an older last event is no agent
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 = ('LANE_ID', 'LANE_WS', 'THROTTLE_TRACE') # + a lanes/ ANTHROPIC_CUSTOM_HEADERS (identity_free)
165
-
166
-
167
- def identity_free(environ, drop=('TMUX',)):
168
- """`environ` without any lane identity (lanes/docs/ARCHITECTURE.md "Lane identity"). Whatever tmux client STARTS
169
- a socket's server hands its whole environment to the server's GLOBAL environment, and every later session on
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, never carrying a lane identity (it could be the client that starts a shared board server)."""
180
- return subprocess.run(argv, capture_output=True, text=True, timeout=15, env=identity_free(os.environ))
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
- return tmux_run(['tmux', '-L', socket, *args])
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
- data = json.loads((ROOT / 'registry.json').read_text())
192
- lanes = data['lanes']
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
- def tmux_text(text):
224
- """tmux's argv parser ends a command at ONE trailing `;` of ANY argument and turns a trailing `\\;` into `;`
225
- (MEASURED, tmux 3.4: `send-keys -l -- 'echo a;'` types `echo a`). Writing that last `;` as `\\;` types the text
226
- exactly, a trailing `\\;` included."""
227
- return text[:-1] + '\\;' if text.endswith(';') else text
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 = ['tmux', '-L', socket, 'attach-session']
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: ours (`v` 1, a vocabulary word), a last event (`at`, else `since`)
258
- within HOOK_MAX_AGE, and a recorded agent pid still alive in this container (and the same process when the
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
- result = tmux_run(send_argv(socket, session, text, enter))
347
- else:
348
- result = zmx('send', zmx_name('send', name), text) # zmx: the bytes as given, never an Enter of its own
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
- rows = text.splitlines()
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"