modelmux-cli 0.1.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.
Files changed (43) hide show
  1. modelmux_cli/__init__.py +8 -0
  2. modelmux_cli/__main__.py +7 -0
  3. modelmux_cli/_shared/providers/claude.json +34 -0
  4. modelmux_cli/_shared/providers/codex.json +35 -0
  5. modelmux_cli/_shared/release.json +4 -0
  6. modelmux_cli/_shared/schema/provider.schema.json +81 -0
  7. modelmux_cli/_shared/templates/compose-service.json +12 -0
  8. modelmux_cli/_shared/templates/config/curl.json +8 -0
  9. modelmux_cli/_shared/templates/config/env.json +8 -0
  10. modelmux_cli/_shared/templates/config/langchain.json +8 -0
  11. modelmux_cli/_shared/templates/config/litellm.json +8 -0
  12. modelmux_cli/_shared/templates/config/openai-python.json +8 -0
  13. modelmux_cli/clientconfig.py +87 -0
  14. modelmux_cli/commands/__init__.py +1 -0
  15. modelmux_cli/commands/basic.py +75 -0
  16. modelmux_cli/commands/config_cmd.py +65 -0
  17. modelmux_cli/commands/doctor.py +190 -0
  18. modelmux_cli/commands/login.py +190 -0
  19. modelmux_cli/commands/logout.py +53 -0
  20. modelmux_cli/commands/up.py +157 -0
  21. modelmux_cli/commands/upgrade.py +50 -0
  22. modelmux_cli/config.py +78 -0
  23. modelmux_cli/console.py +112 -0
  24. modelmux_cli/context.py +41 -0
  25. modelmux_cli/docker.py +340 -0
  26. modelmux_cli/errors.py +35 -0
  27. modelmux_cli/files.py +42 -0
  28. modelmux_cli/health.py +60 -0
  29. modelmux_cli/main.py +119 -0
  30. modelmux_cli/paths.py +51 -0
  31. modelmux_cli/ports.py +29 -0
  32. modelmux_cli/providers.py +168 -0
  33. modelmux_cli/py.typed +0 -0
  34. modelmux_cli/redact.py +35 -0
  35. modelmux_cli/release.py +40 -0
  36. modelmux_cli/secrets_store.py +71 -0
  37. modelmux_cli/stack.py +217 -0
  38. modelmux_cli/terminal.py +44 -0
  39. modelmux_cli/updates.py +50 -0
  40. modelmux_cli-0.1.0.dist-info/METADATA +40 -0
  41. modelmux_cli-0.1.0.dist-info/RECORD +43 -0
  42. modelmux_cli-0.1.0.dist-info/WHEEL +4 -0
  43. modelmux_cli-0.1.0.dist-info/entry_points.txt +2 -0
modelmux_cli/docker.py ADDED
@@ -0,0 +1,340 @@
1
+ """The ONLY module that runs other programs: the ``docker`` CLI.
2
+
3
+ - Commands are argument lists, never shell strings.
4
+ - Every call has a timeout.
5
+ - Docker's raw output is shown only with --verbose (and redacted there);
6
+ failures become ``DockerError``s with a plain message and a next step.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import os
13
+ import re
14
+ import shlex
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ from collections.abc import Callable, Iterator, Sequence
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from modelmux_cli.console import Console
24
+ from modelmux_cli.errors import DockerError
25
+
26
+ DEFAULT_TIMEOUT = 60.0
27
+ PULL_TIMEOUT = 1800.0
28
+
29
+ Runner = Callable[..., "subprocess.CompletedProcess[str]"]
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Result:
34
+ args: tuple[str, ...]
35
+ returncode: int
36
+ stdout: str
37
+ stderr: str
38
+
39
+ @property
40
+ def ok(self) -> bool:
41
+ return self.returncode == 0
42
+
43
+
44
+ def _start_docker_hint(platform: str) -> str:
45
+ if platform in ("darwin", "win32"):
46
+ return "Start Docker Desktop and wait until it says it is running, then try again."
47
+ return "Start the Docker service (for example: sudo systemctl start docker), then try again."
48
+
49
+
50
+ def _install_hint(platform: str) -> str:
51
+ if platform in ("darwin", "win32"):
52
+ return "Install Docker Desktop: https://docs.docker.com/desktop/"
53
+ return "Install Docker Engine: https://docs.docker.com/engine/install/"
54
+
55
+
56
+ # (pattern in Docker's stderr, message, hint factory)
57
+ _CLASSIFIERS: tuple[tuple[re.Pattern[str], str, Callable[[str], str]], ...] = (
58
+ (
59
+ re.compile(r"permission denied while trying to connect", re.I),
60
+ "Your user is not allowed to use Docker.",
61
+ lambda _p: (
62
+ "Add yourself to the docker group (sudo usermod -aG docker $USER), "
63
+ "then log out and back in."
64
+ ),
65
+ ),
66
+ (
67
+ re.compile(
68
+ r"cannot connect to the docker daemon|is the docker daemon running|error during "
69
+ r"connect|docker_engine.*(cannot find|not found)|failed to connect to the docker API",
70
+ re.I,
71
+ ),
72
+ "Docker is installed but not running.",
73
+ _start_docker_hint,
74
+ ),
75
+ (
76
+ re.compile(
77
+ r"'compose' is not a docker command|unknown command.*compose|unknown shorthand"
78
+ r" flag: 'f' in -f",
79
+ re.I,
80
+ ),
81
+ "Docker Compose v2 is not available.",
82
+ lambda _p: "Install the Docker Compose plugin: https://docs.docker.com/compose/install/",
83
+ ),
84
+ (
85
+ re.compile(r"port is already allocated|address already in use|bind: .*in use", re.I),
86
+ "A port ModelMux needs is already in use.",
87
+ lambda _p: "Run 'modelmux doctor' to see which port, or choose another with --port.",
88
+ ),
89
+ (
90
+ re.compile(
91
+ r"manifest unknown|manifest for .* not found|not found: manifest|"
92
+ r"failed to resolve reference .*: not found",
93
+ re.I,
94
+ ),
95
+ "The ModelMux image for this version was not found.",
96
+ lambda _p: (
97
+ "Check for a newer modelmux-cli (pipx upgrade modelmux-cli), or run 'modelmux doctor'."
98
+ ),
99
+ ),
100
+ (
101
+ # GHCR answers a bare "denied" for images that do not exist or are private.
102
+ re.compile(r"pull access denied|unauthorized|error from registry: denied|denied: ", re.I),
103
+ "The image registry refused access (the image may not exist or may be private).",
104
+ lambda _p: (
105
+ "Check that you can reach ghcr.io and are not logged in with expired "
106
+ "credentials (docker logout ghcr.io)."
107
+ ),
108
+ ),
109
+ (
110
+ re.compile(
111
+ r"tls handshake timeout|i/o timeout|no such host|dial tcp|network is unreachable|"
112
+ r"proxyconnect|connection refused|temporary failure in name resolution",
113
+ re.I,
114
+ ),
115
+ "Docker could not reach the internet.",
116
+ lambda _p: (
117
+ "Check your connection or proxy (HTTPS_PROXY, and Docker Desktop's proxy "
118
+ "settings), then try again."
119
+ ),
120
+ ),
121
+ (
122
+ re.compile(r"no space left on device", re.I),
123
+ "Docker has run out of disk space.",
124
+ lambda _p: "Free space with 'docker system prune' (it removes unused images), then retry.",
125
+ ),
126
+ )
127
+
128
+
129
+ def classify(stderr: str, command: str, platform: str | None = None) -> DockerError:
130
+ platform = platform or sys.platform
131
+ for pattern, message, hint in _CLASSIFIERS:
132
+ if pattern.search(stderr):
133
+ return DockerError(message, hint=hint(platform))
134
+ return DockerError(
135
+ f"Docker command failed: docker {command}.",
136
+ hint="Run again with --verbose to see Docker's output.",
137
+ )
138
+
139
+
140
+ @contextlib.contextmanager
141
+ def _raw_terminal(in_fd: int, master: int) -> Iterator[None]:
142
+ """Give the pseudo-terminal our window size and put our terminal in raw mode."""
143
+ if sys.platform == "win32":
144
+ raise OSError("pseudo-terminals need Linux or macOS")
145
+ if not os.isatty(in_fd):
146
+ yield
147
+ return
148
+ import fcntl # noqa: PLC0415 - POSIX only
149
+ import termios # noqa: PLC0415
150
+ import tty # noqa: PLC0415
151
+
152
+ with contextlib.suppress(OSError):
153
+ size = fcntl.ioctl(in_fd, termios.TIOCGWINSZ, b"\0" * 8)
154
+ fcntl.ioctl(master, termios.TIOCSWINSZ, size)
155
+ saved = termios.tcgetattr(in_fd)
156
+ tty.setraw(in_fd)
157
+ try:
158
+ yield
159
+ finally:
160
+ termios.tcsetattr(in_fd, termios.TCSADRAIN, saved)
161
+
162
+
163
+ def _relay(
164
+ master: int,
165
+ in_fd: int,
166
+ out_fd: int,
167
+ on_output: Callable[[bytes], bytes | None],
168
+ timeout: float,
169
+ ) -> None:
170
+ """Copy keystrokes to the program and its output to us until it closes."""
171
+ import select # noqa: PLC0415
172
+ import time # noqa: PLC0415
173
+
174
+ deadline = time.monotonic() + timeout
175
+ watch = [master, in_fd]
176
+ while True:
177
+ remaining = deadline - time.monotonic()
178
+ if remaining <= 0:
179
+ raise TimeoutError
180
+ ready, _, _ = select.select(watch, [], [], min(remaining, 0.5))
181
+ if master in ready:
182
+ try:
183
+ data = os.read(master, 4096)
184
+ except OSError: # EIO: the program closed its terminal
185
+ return
186
+ if not data:
187
+ return
188
+ os.write(out_fd, data)
189
+ if extra := on_output(data):
190
+ os.write(out_fd, extra)
191
+ if in_fd in ready:
192
+ typed = os.read(in_fd, 1024)
193
+ if typed:
194
+ os.write(master, typed)
195
+ else:
196
+ watch = [master] # our stdin closed; keep relaying output
197
+
198
+
199
+ class Docker:
200
+ def __init__(
201
+ self,
202
+ console: Console,
203
+ *,
204
+ runner: Runner | None = None,
205
+ which: Callable[[str], str | None] = shutil.which,
206
+ platform: str | None = None,
207
+ ) -> None:
208
+ self.console = console
209
+ self._runner: Runner = runner or subprocess.run
210
+ self._which = which
211
+ self.platform = platform or sys.platform
212
+ self._binary: str | None = None
213
+
214
+ @property
215
+ def binary(self) -> str:
216
+ if self._binary is None:
217
+ found = self._which("docker")
218
+ if found is None:
219
+ raise DockerError("Docker is not installed.", hint=_install_hint(self.platform))
220
+ self._binary = found
221
+ return self._binary
222
+
223
+ def _argv(self, args: Sequence[str]) -> list[str]:
224
+ for arg in args:
225
+ if not isinstance(arg, str) or "\x00" in arg:
226
+ raise DockerError("Internal error: invalid docker argument.")
227
+ return [self.binary, *args]
228
+
229
+ def run(
230
+ self,
231
+ *args: str,
232
+ timeout: float = DEFAULT_TIMEOUT,
233
+ input_text: str | None = None,
234
+ check: bool = True,
235
+ ) -> Result:
236
+ """Run ``docker <args>`` and capture its output."""
237
+ argv = self._argv(args)
238
+ self.console.detail("$ docker " + shlex.join(args))
239
+ kwargs: dict[str, Any] = {
240
+ "capture_output": True,
241
+ "text": True,
242
+ "encoding": "utf-8",
243
+ "errors": "replace",
244
+ "timeout": timeout,
245
+ "check": False,
246
+ }
247
+ if input_text is not None:
248
+ kwargs["input"] = input_text
249
+ else:
250
+ kwargs["stdin"] = subprocess.DEVNULL
251
+ try:
252
+ proc = self._runner(argv, **kwargs)
253
+ except FileNotFoundError:
254
+ raise DockerError(
255
+ "Docker is not installed.", hint=_install_hint(self.platform)
256
+ ) from None
257
+ except PermissionError:
258
+ raise DockerError(
259
+ "The docker command cannot be executed.", hint=f"Check permissions of {argv[0]}."
260
+ ) from None
261
+ except subprocess.TimeoutExpired:
262
+ raise DockerError(
263
+ f"Docker did not finish 'docker {args[0] if args else ''}' in time.",
264
+ hint="Docker may be busy or stuck; check Docker is healthy and try again.",
265
+ ) from None
266
+ result = Result(tuple(args), proc.returncode, proc.stdout or "", proc.stderr or "")
267
+ if result.stdout.strip():
268
+ self.console.detail(result.stdout.rstrip())
269
+ if result.stderr.strip():
270
+ self.console.detail(result.stderr.rstrip())
271
+ if check and not result.ok:
272
+ raise classify(result.stderr, args[0] if args else "", self.platform)
273
+ return result
274
+
275
+ def passthrough(self, *args: str, timeout: float | None = None) -> int:
276
+ """Run ``docker <args>`` attached to this terminal (logs -f, interactive login).
277
+
278
+ Output is not captured, so it is the user's own Docker output.
279
+ """
280
+ argv = self._argv(args)
281
+ self.console.detail("$ docker " + shlex.join(args))
282
+ try:
283
+ return self._runner(argv, timeout=timeout, check=False).returncode
284
+ except FileNotFoundError:
285
+ raise DockerError(
286
+ "Docker is not installed.", hint=_install_hint(self.platform)
287
+ ) from None
288
+ except subprocess.TimeoutExpired:
289
+ raise DockerError("Docker did not finish in time.", hint="Try again.") from None
290
+
291
+ def run_pty(
292
+ self,
293
+ *args: str,
294
+ on_output: Callable[[bytes], bytes | None],
295
+ timeout: float,
296
+ stdin_fd: int | None = None,
297
+ stdout_fd: int | None = None,
298
+ ) -> int:
299
+ """POSIX: run ``docker <args>`` on a new pseudo-terminal and relay it.
300
+
301
+ Keystrokes go straight from our terminal to the program, and its
302
+ output straight to our terminal; neither is stored. ``on_output`` sees
303
+ each output chunk and may return extra bytes to display (for example
304
+ "opened your browser"). Raises ``TimeoutError`` after ``timeout``.
305
+ """
306
+ if sys.platform == "win32":
307
+ raise OSError("pseudo-terminals need Linux or macOS")
308
+ import pty # noqa: PLC0415 - POSIX only
309
+ import signal # noqa: PLC0415
310
+
311
+ argv = self._argv(args)
312
+ self.console.detail("$ docker " + shlex.join(args))
313
+ in_fd = sys.stdin.fileno() if stdin_fd is None else stdin_fd
314
+ out_fd = sys.stdout.fileno() if stdout_fd is None else stdout_fd
315
+ pid, master = pty.fork()
316
+ if pid == 0: # pragma: no cover - child process
317
+ try:
318
+ os.execv(argv[0], argv)
319
+ finally:
320
+ os._exit(127)
321
+ try:
322
+ with _raw_terminal(in_fd, master):
323
+ _relay(master, in_fd, out_fd, on_output, timeout)
324
+ _, status = os.waitpid(pid, 0)
325
+ return os.waitstatus_to_exitcode(status)
326
+ finally:
327
+ with contextlib.suppress(ChildProcessError, ProcessLookupError, OSError):
328
+ if os.waitpid(pid, os.WNOHANG) == (0, 0):
329
+ os.kill(pid, signal.SIGTERM)
330
+ os.waitpid(pid, 0)
331
+ os.close(master)
332
+
333
+ def compose(self, project: str, file: Path, *args: str, **kwargs: Any) -> Result:
334
+ return self.run("compose", "-p", project, "-f", str(file), *args, **kwargs)
335
+
336
+ def check_available(self) -> str:
337
+ """Docker is installed, running, and has Compose v2. Returns the server version."""
338
+ server = self.run("version", "--format", "{{.Server.Version}}").stdout.strip()
339
+ self.run("compose", "version", "--short")
340
+ return server
modelmux_cli/errors.py ADDED
@@ -0,0 +1,35 @@
1
+ """Errors the CLI reports to the user.
2
+
3
+ Every failure is a ``CliError``: a plain message saying what went wrong, an
4
+ optional hint saying what to do next, and an exit code. ``main`` is the only
5
+ place that prints them.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ EXIT_OK = 0
11
+ EXIT_FAILURE = 1
12
+ EXIT_USAGE = 2
13
+ EXIT_DOCKER = 3
14
+ EXIT_INTERRUPTED = 130
15
+
16
+
17
+ class CliError(Exception):
18
+ exit_code = EXIT_FAILURE
19
+
20
+ def __init__(self, message: str, *, hint: str | None = None) -> None:
21
+ super().__init__(message)
22
+ self.message = message
23
+ self.hint = hint
24
+
25
+
26
+ class UsageError(CliError):
27
+ """The command was used incorrectly."""
28
+
29
+ exit_code = EXIT_USAGE
30
+
31
+
32
+ class DockerError(CliError):
33
+ """Docker is missing, stopped, or a Docker command failed."""
34
+
35
+ exit_code = EXIT_DOCKER
modelmux_cli/files.py ADDED
@@ -0,0 +1,42 @@
1
+ """Small, safe file writes: atomic, and private (0600) from the first byte."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import os
7
+ import secrets
8
+ from pathlib import Path
9
+
10
+ PRIVATE_MODE = 0o600
11
+
12
+
13
+ def write_private(path: Path, data: str) -> None:
14
+ """Atomically replace ``path`` with ``data``, created with mode 0600.
15
+
16
+ The temporary file is created with 0600 (never readable by others, even
17
+ briefly) in the same directory, then renamed over the target.
18
+ """
19
+ tmp = path.with_name(f".{path.name}.{secrets.token_hex(6)}.tmp")
20
+ flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0)
21
+ fd = os.open(tmp, flags, PRIVATE_MODE)
22
+ try:
23
+ with os.fdopen(fd, "wb") as fh:
24
+ fh.write(data.encode("utf-8"))
25
+ fh.flush()
26
+ os.fsync(fh.fileno())
27
+ os.replace(tmp, path)
28
+ except BaseException:
29
+ with contextlib.suppress(OSError):
30
+ tmp.unlink()
31
+ raise
32
+
33
+
34
+ def tighten(path: Path) -> bool:
35
+ """Make an existing file 0600 on POSIX. Returns True if it had to change."""
36
+ if os.name != "posix":
37
+ return False
38
+ mode = path.stat().st_mode & 0o777
39
+ if mode != PRIVATE_MODE:
40
+ path.chmod(PRIVATE_MODE)
41
+ return True
42
+ return False
modelmux_cli/health.py ADDED
@@ -0,0 +1,60 @@
1
+ """Talk to a running ModelMux server over HTTP (localhost only)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import time
7
+ import urllib.error
8
+ import urllib.request
9
+ from collections.abc import Callable
10
+ from typing import Any
11
+
12
+ LOCALHOST = "127.0.0.1"
13
+
14
+
15
+ def base_url(port: int) -> str:
16
+ return f"http://{LOCALHOST}:{port}"
17
+
18
+
19
+ def get_json(
20
+ port: int, path: str, *, timeout: float = 3.0, api_key: str | None = None
21
+ ) -> tuple[int, Any]:
22
+ """GET ``path`` on the local server. Returns (status, body); status 0 if unreachable."""
23
+ request = urllib.request.Request(base_url(port) + path) # noqa: S310 - fixed http://127.0.0.1
24
+ if api_key is not None:
25
+ request.add_header("Authorization", f"Bearer {api_key}")
26
+ try:
27
+ with urllib.request.urlopen(request, timeout=timeout) as response: # noqa: S310
28
+ return response.status, json.loads(response.read() or b"null")
29
+ except urllib.error.HTTPError as exc:
30
+ try:
31
+ body = json.loads(exc.read() or b"null")
32
+ except ValueError:
33
+ body = None
34
+ return exc.code, body
35
+ except (urllib.error.URLError, OSError, ValueError):
36
+ return 0, None
37
+
38
+
39
+ def ready(port: int) -> bool:
40
+ status, body = get_json(port, "/health/ready")
41
+ return status == 200 and isinstance(body, dict) and body.get("status") == "ready"
42
+
43
+
44
+ def wait_until(
45
+ check: Callable[[], bool],
46
+ *,
47
+ timeout: float,
48
+ interval: float = 1.0,
49
+ should_stop: Callable[[], bool] = lambda: False,
50
+ sleep: Callable[[float], None] = time.sleep,
51
+ clock: Callable[[], float] = time.monotonic,
52
+ ) -> bool:
53
+ """Poll ``check`` until it is true (True), ``should_stop`` is true or time runs out (False)."""
54
+ deadline = clock() + timeout
55
+ while True:
56
+ if check():
57
+ return True
58
+ if should_stop() or clock() >= deadline:
59
+ return False
60
+ sleep(interval)
modelmux_cli/main.py ADDED
@@ -0,0 +1,119 @@
1
+ """``modelmux``: set up, log in to and run ModelMux.
2
+
3
+ This module only parses arguments and reports errors. Each command lives in
4
+ ``modelmux_cli.commands``; the commands that are not built yet say so.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import sys
11
+ import traceback
12
+ from collections.abc import Callable, Sequence
13
+
14
+ from modelmux_cli import __version__
15
+ from modelmux_cli.clientconfig import TARGETS
16
+ from modelmux_cli.commands import basic, config_cmd
17
+ from modelmux_cli.commands import doctor as doctor_command
18
+ from modelmux_cli.commands import login as login_command
19
+ from modelmux_cli.commands import logout as logout_command
20
+ from modelmux_cli.commands import up as up_command
21
+ from modelmux_cli.commands import upgrade as upgrade_command
22
+ from modelmux_cli.console import Console
23
+ from modelmux_cli.errors import EXIT_FAILURE, EXIT_INTERRUPTED, CliError
24
+ from modelmux_cli.providers import load_providers
25
+
26
+ Handler = Callable[[argparse.Namespace, Console], int]
27
+
28
+
29
+ def build_parser() -> argparse.ArgumentParser:
30
+ providers = sorted(load_providers())
31
+ parser = argparse.ArgumentParser(
32
+ prog="modelmux",
33
+ description="Run AI coding CLIs as a secure, OpenAI-compatible API.",
34
+ epilog="Start with: modelmux up, then modelmux login <provider>.",
35
+ )
36
+ parser.add_argument("--version", action="version", version=f"modelmux {__version__}")
37
+ parser.add_argument("-v", "--verbose", action="store_true", help="show details and commands")
38
+ parser.add_argument("--no-color", action="store_true", help="disable coloured output")
39
+ sub = parser.add_subparsers(dest="command", metavar="<command>", required=True)
40
+
41
+ up = sub.add_parser("up", help="start ModelMux (logged-in providers)")
42
+ up.add_argument(
43
+ "providers", nargs="*", metavar="provider", help=f"any of: {', '.join(providers)}"
44
+ )
45
+ up.add_argument("--image", metavar="REF", help="server image to run (remembered)")
46
+ up.add_argument(
47
+ "--port", action="append", metavar="PROVIDER=PORT", help="local port (remembered)"
48
+ )
49
+ up.set_defaults(handler=up_command.run)
50
+
51
+ down = sub.add_parser("down", help="stop ModelMux (logins are kept)")
52
+ down.set_defaults(handler=basic.down)
53
+
54
+ logs = sub.add_parser("logs", help="show server logs")
55
+ logs.add_argument("provider", nargs="?", choices=providers)
56
+ logs.add_argument("-f", "--follow", action="store_true", help="keep streaming new lines")
57
+ logs.add_argument("--tail", type=int, default=100, metavar="N", help="lines to show (100)")
58
+ logs.set_defaults(handler=basic.logs)
59
+
60
+ status = sub.add_parser("status", help="what is running, logged in and healthy")
61
+ status.set_defaults(handler=basic.status)
62
+
63
+ login = sub.add_parser("login", help="log a provider in (guided)")
64
+ login.add_argument("provider", choices=providers)
65
+ login.add_argument("--method", help="login method (see the provider's options)")
66
+ login.add_argument("--no-browser", action="store_true", help="do not open a browser")
67
+ login.add_argument("--raw", action="store_true", help="show the provider's raw output")
68
+ login.add_argument("--force", action="store_true", help="log in again even if logged in")
69
+ login.set_defaults(handler=login_command.run)
70
+
71
+ logout = sub.add_parser("logout", help="remove a provider's saved login")
72
+ logout.add_argument("provider", choices=providers)
73
+ logout.add_argument("-y", "--yes", action="store_true", help="do not ask for confirmation")
74
+ logout.set_defaults(handler=logout_command.run)
75
+
76
+ config = sub.add_parser("config", help="print ready-to-paste client config")
77
+ config.add_argument("target", choices=TARGETS)
78
+ config.add_argument("--provider", choices=providers)
79
+ config.add_argument("--reveal-key", action="store_true", help="include the real API key")
80
+ config.set_defaults(handler=config_cmd.config)
81
+
82
+ doctor = sub.add_parser("doctor", help="diagnose common problems")
83
+ doctor.set_defaults(handler=doctor_command.run)
84
+
85
+ upgrade = sub.add_parser("upgrade", help="run the image matching this CLI version")
86
+ upgrade.add_argument("--image", metavar="REF", help="use a different image (advanced)")
87
+ upgrade.set_defaults(handler=upgrade_command.run)
88
+
89
+ key = sub.add_parser("key", help="the API key clients use to call ModelMux")
90
+ key_sub = key.add_subparsers(dest="key_command", metavar="<action>", required=True)
91
+ key_show = key_sub.add_parser("show", help="print the API key")
92
+ key_show.set_defaults(handler=config_cmd.key_show)
93
+ return parser
94
+
95
+
96
+ def main(argv: Sequence[str] | None = None) -> int:
97
+ raw = list(sys.argv[1:] if argv is None else argv)
98
+ # Until arguments are parsed, honour --verbose from the raw command line.
99
+ console = Console(verbose="-v" in raw or "--verbose" in raw)
100
+ try:
101
+ parser = build_parser()
102
+ args = parser.parse_args(raw)
103
+ console = Console(verbose=args.verbose, color=False if args.no_color else None)
104
+ handler: Handler = args.handler
105
+ return handler(args, console)
106
+ except CliError as exc:
107
+ console.error(exc.message, exc.hint)
108
+ return exc.exit_code
109
+ except KeyboardInterrupt:
110
+ console.error("Cancelled.")
111
+ return EXIT_INTERRUPTED
112
+ except Exception:
113
+ console.error(
114
+ "Something unexpected went wrong.",
115
+ "Run again with --verbose for details, and please report it if it keeps happening.",
116
+ )
117
+ if console.verbose:
118
+ console.detail(traceback.format_exc())
119
+ return EXIT_FAILURE
modelmux_cli/paths.py ADDED
@@ -0,0 +1,51 @@
1
+ """Where the CLI keeps its own files.
2
+
3
+ | OS | Directory |
4
+ |---------|----------------------------------------------------|
5
+ | Linux | ``$XDG_CONFIG_HOME/modelmux`` (``~/.config/modelmux``) |
6
+ | macOS | ``~/Library/Application Support/modelmux`` |
7
+ | Windows | ``%APPDATA%\\modelmux`` |
8
+
9
+ ``MODELMUX_CLI_HOME`` overrides it (tests, portable setups). The directory
10
+ holds only small files: ``config.json``, ``secrets.env`` (mode 0600) and the
11
+ generated ``compose.yaml``. Logins never live here: they stay in Docker
12
+ volumes.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import sys
19
+ from collections.abc import Mapping
20
+ from pathlib import Path, PurePosixPath
21
+
22
+ APP_NAME = "modelmux"
23
+ OVERRIDE_ENV = "MODELMUX_CLI_HOME"
24
+
25
+
26
+ def config_dir(
27
+ env: Mapping[str, str] | None = None,
28
+ platform: str | None = None,
29
+ home: Path | None = None,
30
+ ) -> Path:
31
+ env = os.environ if env is None else env
32
+ platform = platform or sys.platform
33
+ if override := env.get(OVERRIDE_ENV):
34
+ return Path(override).expanduser()
35
+ home = home or Path.home()
36
+ if platform == "win32":
37
+ base = Path(env["APPDATA"]) if env.get("APPDATA") else home / "AppData" / "Roaming"
38
+ elif platform == "darwin":
39
+ base = home / "Library" / "Application Support"
40
+ else:
41
+ xdg = env.get("XDG_CONFIG_HOME")
42
+ base = Path(xdg) if xdg and PurePosixPath(xdg).is_absolute() else home / ".config"
43
+ return base / APP_NAME
44
+
45
+
46
+ def ensure_private_dir(path: Path) -> Path:
47
+ """Create ``path`` if needed; on POSIX make it accessible to the owner only."""
48
+ path.mkdir(parents=True, exist_ok=True)
49
+ if os.name == "posix":
50
+ path.chmod(0o700)
51
+ return path
modelmux_cli/ports.py ADDED
@@ -0,0 +1,29 @@
1
+ """Is a local port free? (Checked with a socket bind: no extra processes.)"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import socket
6
+ import sys
7
+
8
+ from modelmux_cli.health import LOCALHOST
9
+
10
+
11
+ def port_free(port: int, host: str = LOCALHOST) -> bool:
12
+ """True unless another program is listening on ``host:port``.
13
+
14
+ Ports in TIME_WAIT (for example right after ``modelmux down``) count as
15
+ free, as they do for Docker: on POSIX ``SO_REUSEADDR`` ignores TIME_WAIT
16
+ but still fails against a live listener. On Windows ``SO_REUSEADDR`` would
17
+ allow stealing a port, so ``SO_EXCLUSIVEADDRUSE`` is used instead.
18
+ """
19
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
20
+ if sys.platform == "win32":
21
+ exclusive = getattr(socket, "SO_EXCLUSIVEADDRUSE") # noqa: B009 - Windows-only name
22
+ sock.setsockopt(socket.SOL_SOCKET, exclusive, 1)
23
+ else:
24
+ sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
25
+ try:
26
+ sock.bind((host, port))
27
+ except OSError:
28
+ return False
29
+ return True