tmuxd 1.0.0__py3-none-any.whl

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.
tmuxd/__init__.py ADDED
@@ -0,0 +1,62 @@
1
+ """tmuxd -- terminals that outlive the connection and can be typed into.
2
+
3
+ from tmuxd import Tmuxd
4
+
5
+ t = Tmuxd(port=12345, token="changeme") # ttyd is up; tmux is not yet
6
+ s = t.session(id="id5", cwd="~/proj", cmd="claude")
7
+ s.send("run the tests", enter=True)
8
+ print(s.url) # http://localhost:12345/?arg=id5
9
+
10
+ The library is the core. The CLI (``tmuxd.cli``) and the HTTP endpoint
11
+ (``tmuxd.http``, off by default) are shells around it.
12
+
13
+ Design notes live in ``docs/v1/works/``.
14
+ """
15
+
16
+ __version__ = "1.0.0"
17
+
18
+ from .core import Tmuxd
19
+ from .errors import (
20
+ BadId,
21
+ NoSuchSession,
22
+ PlatformError,
23
+ PortInUse,
24
+ SessionError,
25
+ SessionExists,
26
+ TmuxdError,
27
+ TmuxGone,
28
+ TmuxMissing,
29
+ TtydFailed,
30
+ TtydMissing,
31
+ Unauthorized,
32
+ Unreachable,
33
+ )
34
+ from .session import Session
35
+
36
+ __all__ = [
37
+ "Tmuxd",
38
+ "Session",
39
+ "TmuxdError",
40
+ "SessionError",
41
+ "NoSuchSession",
42
+ "SessionExists",
43
+ "BadId",
44
+ "PlatformError",
45
+ "TmuxGone",
46
+ "TmuxMissing",
47
+ "TtydMissing",
48
+ "TtydFailed",
49
+ "PortInUse",
50
+ "Unauthorized",
51
+ "Unreachable",
52
+ "__version__",
53
+ ]
54
+
55
+
56
+ def __getattr__(name):
57
+ # Kept lazy so `import tmuxd` never drags the HTTP shell in with it.
58
+ if name == "RemoteTmuxd":
59
+ from .remote import RemoteTmuxd
60
+
61
+ return RemoteTmuxd
62
+ raise AttributeError(name)
tmuxd/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
tmuxd/cli.py ADDED
@@ -0,0 +1,511 @@
1
+ """The command line -- the third shell around the library.
2
+
3
+ Locally it calls the library directly; ``-H`` is the only mode that speaks
4
+ HTTP. Eleven commands, eleven library calls (works/04-cli.md §9).
5
+
6
+ Exit codes matter more than the text: 0 fine, 2 usage, 3 no such session,
7
+ 4 wrong state, 5 cannot reach a remote, 6 the tmux server is gone.
8
+ """
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import shlex
14
+ import subprocess
15
+ import sys
16
+ import time
17
+
18
+ from . import __version__
19
+ from .errors import (
20
+ NoSuchSession,
21
+ PlatformError,
22
+ SessionError,
23
+ TmuxdError,
24
+ TmuxGone,
25
+ Unauthorized,
26
+ Unreachable,
27
+ )
28
+
29
+ EXIT_OK = 0
30
+ EXIT_FAIL = 1
31
+ EXIT_USAGE = 2
32
+ EXIT_NO_SESSION = 3
33
+ EXIT_STATE = 4
34
+ EXIT_UNREACHABLE = 5
35
+ EXIT_TMUX_GONE = 6
36
+
37
+ CONFIG_PATH = os.environ.get("TMUXD_CONFIG") or os.path.expanduser("~/.tmuxd.conf")
38
+ REMOTE_ONLY_MESSAGE = (
39
+ "not available against a remote tmuxd -- ttyd's lifetime belongs to the "
40
+ "process over there"
41
+ )
42
+
43
+
44
+ # -- config ---------------------------------------------------------------
45
+
46
+
47
+ def read_config(path=None):
48
+ """``set -g key value`` lines, tmux style. Just another way to spell the
49
+ constructor arguments -- not a second source of truth."""
50
+ path = path or os.environ.get("TMUXD_CONFIG") or CONFIG_PATH
51
+ values = {}
52
+ try:
53
+ with open(path, encoding="utf-8") as fh:
54
+ lines = fh.readlines()
55
+ except OSError:
56
+ return values
57
+ for line in lines:
58
+ line = line.strip()
59
+ if not line or line.startswith("#"):
60
+ continue
61
+ parts = shlex.split(line)
62
+ if len(parts) >= 4 and parts[0] == "set" and parts[1] == "-g":
63
+ values[parts[2]] = parts[3]
64
+ elif len(parts) >= 3 and parts[0] == "set":
65
+ values[parts[1]] = parts[2]
66
+ return values
67
+
68
+
69
+ # -- connecting -----------------------------------------------------------
70
+
71
+
72
+ def build(args, *, start_ttyd=False):
73
+ """A local Tmuxd, or a RemoteTmuxd when -H was given."""
74
+ if args.host:
75
+ from .remote import RemoteTmuxd
76
+
77
+ return RemoteTmuxd(args.host, token=args.token or os.environ.get("TMUXD_TOKEN"))
78
+
79
+ from .core import Tmuxd
80
+
81
+ conf = read_config()
82
+ port = args.port or conf.get("port")
83
+ return Tmuxd(
84
+ port=int(port) if port else None,
85
+ bind=args.bind or conf.get("bind") or None,
86
+ token=args.token or conf.get("token") or None,
87
+ socket=args.socket or conf.get("socket") or None,
88
+ history_limit=conf.get("history-limit"),
89
+ tmux_bin=conf.get("tmux-bin") or None,
90
+ state_dir=args.state_dir or conf.get("state-dir") or None,
91
+ start_ttyd=start_ttyd,
92
+ )
93
+
94
+
95
+ def daemon_file(t):
96
+ return os.path.join(t.state_dir, "daemon.json")
97
+
98
+
99
+ # -- commands -------------------------------------------------------------
100
+
101
+
102
+ def cmd_serve(args):
103
+ from .core import Tmuxd
104
+
105
+ conf = read_config()
106
+ port = args.port or conf.get("port") or 7681
107
+ t = Tmuxd(
108
+ port=int(port),
109
+ bind=args.bind or conf.get("bind") or None,
110
+ token=args.token or conf.get("token") or None,
111
+ socket=args.socket or conf.get("socket") or None,
112
+ history_limit=conf.get("history-limit"),
113
+ state_dir=args.state_dir or conf.get("state-dir") or None,
114
+ )
115
+ with open(daemon_file(t), "w", encoding="utf-8") as fh:
116
+ json.dump({"pid": os.getpid(), "port": t.port, "started_at": time.time()}, fh)
117
+
118
+ if args.http_port:
119
+ t.serve_http(args.http_port, token=t.token)
120
+ print("http: http://%s:%d/api" % (t.bind, args.http_port))
121
+
122
+ _banner(t)
123
+ try:
124
+ while True:
125
+ time.sleep(3600)
126
+ except KeyboardInterrupt:
127
+ pass
128
+ finally:
129
+ t.close()
130
+ try:
131
+ os.unlink(daemon_file(t))
132
+ except OSError:
133
+ pass
134
+ return EXIT_OK
135
+
136
+
137
+ def _banner(t):
138
+ print("tmuxd %s" % __version__)
139
+ print("ttyd: http://%s:%d %s" % (
140
+ t.bind, t.port, ("token=%s…" % t.token[:8]) if t.token else "no token (open)"))
141
+ info = t.info()["tmux"]
142
+ print("tmux: %s %s socket=%s (dedicated) %s" % (
143
+ info["bin"], info["version"], info["socket"],
144
+ "server running" if info["running"] else "server not started yet"))
145
+
146
+
147
+ def cmd_start(args):
148
+ t = build(args, start_ttyd=False)
149
+ _reject_remote(t)
150
+ path = daemon_file(t)
151
+ existing = _read_json(path)
152
+ if existing and _alive(existing.get("pid")):
153
+ print("already running (pid %d)" % existing["pid"])
154
+ return EXIT_OK
155
+
156
+ # Global flags come before the subcommand -- argparse will not take them
157
+ # afterwards, and getting that wrong makes `start` fail in a way whose
158
+ # only trace is the daemon log.
159
+ argv = [sys.executable, "-m", "tmuxd"]
160
+ for flag, value in (
161
+ ("-L", args.socket), ("--port", args.port), ("--bind", args.bind),
162
+ ("--token", args.token), ("--state-dir", args.state_dir),
163
+ ):
164
+ if value:
165
+ argv += [flag, str(value)]
166
+ argv.append("serve")
167
+ if args.http_port:
168
+ argv += ["--http-port", str(args.http_port)]
169
+
170
+ log = open(os.path.join(t.state_dir, "daemon.log"), "ab")
171
+ subprocess.Popen(argv, stdout=log, stderr=log, start_new_session=True)
172
+
173
+ deadline = time.time() + 10
174
+ while time.time() < deadline:
175
+ record = _read_json(path)
176
+ if record and _alive(record.get("pid")):
177
+ _banner(build(args, start_ttyd=False))
178
+ return EXIT_OK
179
+ time.sleep(0.1)
180
+
181
+ sys.stderr.write("tmuxd did not come up. Tail of %s:\n" % log.name)
182
+ sys.stderr.write(_tail(log.name))
183
+ return EXIT_FAIL
184
+
185
+
186
+ def cmd_stop(args):
187
+ t = build(args, start_ttyd=False)
188
+ _reject_remote(t)
189
+ record = _read_json(daemon_file(t))
190
+ if not record or not _alive(record.get("pid")):
191
+ print("not running")
192
+ return EXIT_OK
193
+ os.kill(record["pid"], 15)
194
+ for _ in range(50):
195
+ if not _alive(record["pid"]):
196
+ break
197
+ time.sleep(0.1)
198
+ live = [s for s in t.sessions() if s.alive]
199
+ print("ttyd stopped. %d session(s) still running (tmuxd start brings the door back)."
200
+ % len(live))
201
+ return EXIT_OK
202
+
203
+
204
+ def cmd_status(args):
205
+ t = build(args, start_ttyd=False)
206
+ _reject_remote(t)
207
+ record = _read_json(daemon_file(t))
208
+ pid = record.get("pid") if record else None
209
+ running = bool(pid and _alive(pid))
210
+ listening = False
211
+ if t.port:
212
+ from .ttyd import port_open
213
+
214
+ listening = port_open(t.bind, t.port)
215
+ if args.json:
216
+ print(json.dumps({"daemon": running, "pid": pid, "port": t.port,
217
+ "listening": listening}, indent=2))
218
+ else:
219
+ print("daemon: %s" % ("running (pid %d)" % pid if running else "not running"))
220
+ print("ttyd port: %s" % ("listening on %d" % t.port if listening else "not listening"))
221
+ return EXIT_OK if running or listening else EXIT_FAIL
222
+
223
+
224
+ def cmd_info(args):
225
+ t = build(args, start_ttyd=False)
226
+ info = t.info()
227
+ if args.json:
228
+ print(json.dumps(info, indent=2))
229
+ return EXIT_OK
230
+ print("tmuxd %s" % info["version"])
231
+ if info.get("ttyd"):
232
+ print("ttyd %(version)s port=%(port)s owned=%(owned)s" % info["ttyd"])
233
+ print("tmux %(version)s %(bin)s socket=%(socket)s running=%(running)s" % info["tmux"])
234
+ print("sessions %(total)d total %(alive)d alive %(exited)d exited %(external)d external"
235
+ % info["sessions"])
236
+ return EXIT_OK
237
+
238
+
239
+ def cmd_new(args):
240
+ t = build(args)
241
+ # argparse.REMAINDER hands back the "--" separator too; it is punctuation,
242
+ # not the first word of the command.
243
+ words = list(args.command or [])
244
+ if words and words[0] == "--":
245
+ words = words[1:]
246
+ cmd = " ".join(shlex.quote(part) for part in words) if words else None
247
+ env = dict(pair.split("=", 1) for pair in args.env) if args.env else None
248
+ session = t.session(id=args.session, cwd=args.cwd, cmd=cmd, env=env)
249
+ if args.json:
250
+ print(json.dumps(session.to_dict(), indent=2))
251
+ else:
252
+ print("%s → %s" % (session.id, session.url or "(no ttyd port configured)"))
253
+ return EXIT_OK
254
+
255
+
256
+ def cmd_ls(args):
257
+ t = build(args)
258
+ sessions = t.sessions()
259
+ if args.json:
260
+ print(json.dumps([s.to_dict() for s in sessions], indent=2))
261
+ return EXIT_OK
262
+ if args.format:
263
+ for s in sessions:
264
+ print(_format(args.format, s))
265
+ return EXIT_OK
266
+ for s in sessions:
267
+ status = s.status
268
+ if status == "alive":
269
+ print("%-20s alive %d client%s %-8s %s" % (
270
+ s.id, s.clients, " " if s.clients == 1 else "s",
271
+ s.current_command or "-", s.cwd or "-"))
272
+ else:
273
+ print("%-20s exited %s" % (s.id, "swept in %d days" % (t.gc_ttl // 86400)))
274
+ return EXIT_OK
275
+
276
+
277
+ def cmd_url(args):
278
+ t = build(args)
279
+ session = t.get(args.target)
280
+ url = session.url
281
+ if not url:
282
+ sys.stderr.write("no ttyd port configured for this instance\n")
283
+ return EXIT_FAIL
284
+ print(url)
285
+ if args.open:
286
+ if not args.host:
287
+ from .ttyd import port_open
288
+
289
+ if not port_open(t.bind, t.port):
290
+ sys.stderr.write(
291
+ "warning: nothing is listening on port %d -- start one with "
292
+ "`tmuxd start`\n" % t.port)
293
+ opener = read_config().get("open-cmd")
294
+ argv = (shlex.split(opener.replace("%u", url)) if opener
295
+ else ["xdg-open" if sys.platform != "darwin" else "open", url])
296
+ subprocess.Popen(argv, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
297
+ return EXIT_OK
298
+
299
+
300
+ def cmd_kill(args):
301
+ t = build(args)
302
+ clients = t.get(args.target).kill()
303
+ print("killed %s%s" % (args.target,
304
+ " (%d client(s) thrown out)" % clients if clients else ""))
305
+ return EXIT_OK
306
+
307
+
308
+ def cmd_rename(args):
309
+ t = build(args)
310
+ t.get(args.target).rename(args.new_id)
311
+ print("%s → %s" % (args.target, args.new_id))
312
+ return EXIT_OK
313
+
314
+
315
+ def cmd_has(args):
316
+ t = build(args)
317
+ return EXIT_OK if t.has(args.target) else EXIT_NO_SESSION
318
+
319
+
320
+ def cmd_send(args):
321
+ t = build(args)
322
+ t.get(args.target).send(args.text, enter=args.enter)
323
+ print("✓ sent")
324
+ return EXIT_OK
325
+
326
+
327
+ def cmd_keys(args):
328
+ t = build(args)
329
+ t.get(args.target).send_key(*args.keys)
330
+ print("✓ sent")
331
+ return EXIT_OK
332
+
333
+
334
+ def cmd_kill_server(args):
335
+ t = build(args)
336
+ _reject_remote(t)
337
+ if not args.tmux:
338
+ sys.stderr.write("pass --tmux to confirm: this destroys every session in "
339
+ "this pool\n")
340
+ return EXIT_USAGE
341
+ if sys.stdin.isatty() and not args.yes:
342
+ answer = input("kill the tmux server for socket %s? [y/N] " % t.tmux_socket)
343
+ if answer.strip().lower() not in ("y", "yes"):
344
+ return EXIT_OK
345
+ t.kill_tmux_server()
346
+ print("tmux server killed (socket %s). Your own tmux is untouched." % t.tmux_socket)
347
+ return EXIT_OK
348
+
349
+
350
+ # -- helpers --------------------------------------------------------------
351
+
352
+
353
+ def _format(spec, session):
354
+ values = {
355
+ "#{session_id}": session.id,
356
+ "#{session_name}": session.id,
357
+ "#{session_status}": session.status,
358
+ "#{session_attached}": str(session.clients),
359
+ "#{session_cwd}": session.cwd or "",
360
+ "#{session_cmd}": session.cmd or "",
361
+ "#{session_url}": session.url or "",
362
+ "#{pane_current_command}": session.current_command or "",
363
+ }
364
+ out = spec
365
+ for key, value in values.items():
366
+ out = out.replace(key, value)
367
+ return out
368
+
369
+
370
+ def _read_json(path):
371
+ try:
372
+ with open(path, encoding="utf-8") as fh:
373
+ return json.load(fh)
374
+ except (OSError, ValueError):
375
+ return None
376
+
377
+
378
+ def _alive(pid):
379
+ if not pid:
380
+ return False
381
+ try:
382
+ os.kill(int(pid), 0)
383
+ except (ProcessLookupError, ValueError, TypeError):
384
+ return False
385
+ except PermissionError:
386
+ return True
387
+ return True
388
+
389
+
390
+ def _tail(path, lines=15):
391
+ try:
392
+ with open(path, encoding="utf-8", errors="replace") as fh:
393
+ return "".join(fh.readlines()[-lines:])
394
+ except OSError:
395
+ return "(no log)\n"
396
+
397
+
398
+ def _reject_remote(t):
399
+ if t.__class__.__name__ == "RemoteTmuxd":
400
+ raise SystemExit(_fail(REMOTE_ONLY_MESSAGE, EXIT_USAGE))
401
+
402
+
403
+ def _fail(message, code):
404
+ sys.stderr.write("✗ %s\n" % message)
405
+ return code
406
+
407
+
408
+ # -- parser ---------------------------------------------------------------
409
+
410
+
411
+ def build_parser():
412
+ p = argparse.ArgumentParser(prog="tmuxd", description=__doc__.split("\n")[0])
413
+ p.add_argument("--version", action="version", version="tmuxd %s" % __version__)
414
+ p.add_argument("-L", "--socket", help="instance name (also picks the tmux socket)")
415
+ p.add_argument("-H", "--host", default=os.environ.get("TMUXD_HOST"),
416
+ help="drive a remote tmuxd over HTTP")
417
+ p.add_argument("--token")
418
+ p.add_argument("--port", type=int)
419
+ p.add_argument("--bind")
420
+ p.add_argument("--state-dir")
421
+ p.add_argument("--json", action="store_true", help="raw JSON output")
422
+
423
+ sub = p.add_subparsers(dest="command", required=True)
424
+
425
+ serve = sub.add_parser("serve", help="run in the foreground, holding ttyd up")
426
+ serve.add_argument("--http-port", type=int, help="also expose the HTTP shell")
427
+ serve.set_defaults(func=cmd_serve)
428
+
429
+ start = sub.add_parser("start", help="run in the background")
430
+ start.add_argument("--http-port", type=int)
431
+ start.set_defaults(func=cmd_start)
432
+
433
+ sub.add_parser("stop", help="stop ttyd; sessions keep running").set_defaults(func=cmd_stop)
434
+ sub.add_parser("status", help="check back on what is actually up").set_defaults(func=cmd_status)
435
+ sub.add_parser("info", help="versions, ttyd, tmux, session counts").set_defaults(func=cmd_info)
436
+
437
+ new = sub.add_parser("new", help="create or attach to a session")
438
+ new.add_argument("-s", "--session", help="session id (generated when omitted)")
439
+ new.add_argument("-c", "--cwd")
440
+ new.add_argument("-e", "--env", action="append", metavar="K=V")
441
+ new.add_argument("command", nargs=argparse.REMAINDER,
442
+ help="command to run (after --)")
443
+ new.set_defaults(func=cmd_new)
444
+
445
+ ls = sub.add_parser("ls", help="list sessions")
446
+ ls.add_argument("-F", "--format", help="tmux-style format string")
447
+ ls.set_defaults(func=cmd_ls)
448
+
449
+ url = sub.add_parser("url", help="print the entrance URL")
450
+ url.add_argument("-t", "--target", required=True)
451
+ url.add_argument("-o", "--open", action="store_true", help="open a browser too")
452
+ url.set_defaults(func=cmd_url)
453
+
454
+ kill = sub.add_parser("kill", help="destroy a session")
455
+ kill.add_argument("-t", "--target", required=True)
456
+ kill.set_defaults(func=cmd_kill)
457
+
458
+ rename = sub.add_parser("rename", help="change a session id")
459
+ rename.add_argument("-t", "--target", required=True)
460
+ rename.add_argument("new_id")
461
+ rename.set_defaults(func=cmd_rename)
462
+
463
+ has = sub.add_parser("has", help="exit 0 if the session exists")
464
+ has.add_argument("-t", "--target", required=True)
465
+ has.set_defaults(func=cmd_has)
466
+
467
+ send = sub.add_parser("send", help="type literal text into a session")
468
+ send.add_argument("-t", "--target", required=True)
469
+ send.add_argument("text")
470
+ send.add_argument("--enter", action="store_true")
471
+ send.set_defaults(func=cmd_send)
472
+
473
+ keys = sub.add_parser("keys", help="press tmux key names")
474
+ keys.add_argument("-t", "--target", required=True)
475
+ keys.add_argument("keys", nargs="+")
476
+ keys.set_defaults(func=cmd_keys)
477
+
478
+ ks = sub.add_parser("kill-server", help="destroy every session in this pool")
479
+ ks.add_argument("--tmux", action="store_true", help="required confirmation")
480
+ ks.add_argument("-y", "--yes", action="store_true")
481
+ ks.set_defaults(func=cmd_kill_server)
482
+
483
+ return p
484
+
485
+
486
+ def main(argv=None):
487
+ args = build_parser().parse_args(argv)
488
+ try:
489
+ return args.func(args)
490
+ except SystemExit as exc:
491
+ return exc.code
492
+ except NoSuchSession as exc:
493
+ return _fail(exc.message, EXIT_NO_SESSION)
494
+ except SessionError as exc:
495
+ return _fail("%s: %s" % (exc.code, exc.message), EXIT_STATE)
496
+ except (Unreachable, Unauthorized) as exc:
497
+ return _fail("%s: %s" % (exc.code, exc.message), EXIT_UNREACHABLE)
498
+ except TmuxGone as exc:
499
+ return _fail("%s: %s" % (exc.code, exc.message), EXIT_TMUX_GONE)
500
+ except PlatformError as exc:
501
+ return _fail("%s: %s" % (exc.code, exc.message), EXIT_FAIL)
502
+ except TmuxdError as exc:
503
+ return _fail("%s: %s" % (exc.code, exc.message), EXIT_FAIL)
504
+ except ValueError as exc:
505
+ return _fail(str(exc), EXIT_STATE)
506
+ except KeyboardInterrupt:
507
+ return EXIT_FAIL
508
+
509
+
510
+ if __name__ == "__main__":
511
+ sys.exit(main())