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 +62 -0
- tmuxd/__main__.py +6 -0
- tmuxd/cli.py +511 -0
- tmuxd/core.py +340 -0
- tmuxd/data/attach.sh +25 -0
- tmuxd/data/tmux.conf +18 -0
- tmuxd/errors.py +95 -0
- tmuxd/http.py +243 -0
- tmuxd/remote.py +161 -0
- tmuxd/session.py +126 -0
- tmuxd/state.py +128 -0
- tmuxd/tmux.py +179 -0
- tmuxd/ttyd.py +210 -0
- tmuxd-1.0.0.dist-info/METADATA +71 -0
- tmuxd-1.0.0.dist-info/RECORD +19 -0
- tmuxd-1.0.0.dist-info/WHEEL +5 -0
- tmuxd-1.0.0.dist-info/entry_points.txt +2 -0
- tmuxd-1.0.0.dist-info/licenses/LICENSE +201 -0
- tmuxd-1.0.0.dist-info/top_level.txt +1 -0
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
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())
|