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
@@ -0,0 +1,190 @@
1
+ """``modelmux login <provider>``: guided login, then a real test.
2
+
3
+ The provider's own login command runs in a short-lived, hardened helper
4
+ container with only that provider's login volume. On Linux and macOS it runs
5
+ on a pseudo-terminal so the login link can be spotted and opened in the
6
+ browser while the user still types or pastes codes directly into the CLI.
7
+ On Windows (or with ``--raw``) the user's console is attached directly.
8
+
9
+ Keystrokes, pasted codes and API keys are never stored or logged.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import contextlib
16
+ import getpass
17
+ import os
18
+ import secrets
19
+ import signal
20
+ import sys
21
+ import threading
22
+ import webbrowser
23
+ from collections.abc import Callable, Iterator
24
+
25
+ from modelmux_cli import health
26
+ from modelmux_cli.commands import up as up_module
27
+ from modelmux_cli.console import Console
28
+ from modelmux_cli.context import Context, make_context
29
+ from modelmux_cli.errors import CliError, DockerError, UsageError
30
+ from modelmux_cli.providers import LoginMethod, Provider, get_provider
31
+ from modelmux_cli.redact import register_secret
32
+ from modelmux_cli.stack import resolve_image, service_name
33
+ from modelmux_cli.terminal import LinkScanner
34
+
35
+ SECRET_LOGIN_TIMEOUT = 120.0
36
+
37
+ # Replaceable in tests.
38
+ open_browser: Callable[[str], bool] = webbrowser.open
39
+ read_secret: Callable[[str], str] = getpass.getpass
40
+
41
+
42
+ def use_pty(args: argparse.Namespace) -> bool:
43
+ return os.name == "posix" and not args.raw
44
+
45
+
46
+ def choose_method(provider: Provider, requested: str | None) -> LoginMethod:
47
+ name = requested or provider.default_login_method
48
+ if name not in provider.login_methods:
49
+ options = ", ".join(f"{m.name} ({m.description})" for m in provider.login_methods.values())
50
+ raise UsageError(
51
+ f"{provider.display_name} has no login method {name!r}.", hint=f"Choose: {options}"
52
+ )
53
+ return provider.login_methods[name]
54
+
55
+
56
+ def ensure_running_and_tested(ctx: Context, provider: Provider) -> None:
57
+ """(Re)start the provider and wait for /health/ready: the server's startup
58
+ check makes one tiny real request, so ready means the login works."""
59
+ port = ctx.config.port(provider.name)
60
+ state = ctx.stack.states().get(service_name(provider))
61
+ if state is not None and state.state == "running" and health.ready(port):
62
+ ctx.console.success(f"{provider.display_name} is running at {health.base_url(port)}/v1")
63
+ else:
64
+ up_module.check_ports(ctx, [provider])
65
+ ctx.stack.compose(
66
+ "up", "-d", "--force-recreate", service_name(provider),
67
+ timeout=up_module.COMPOSE_UP_TIMEOUT,
68
+ ) # fmt: skip
69
+ up_module.start_and_wait(ctx, provider)
70
+ ctx.console.success(f"{provider.display_name} is logged in and tested.")
71
+ ctx.console.step("Configure your tools: modelmux config litellm (or openai-python, langchain)")
72
+
73
+
74
+ def _link_handler(
75
+ provider: Provider, console: Console, no_browser: bool
76
+ ) -> Callable[[bytes], bytes | None]:
77
+ scanner = LinkScanner(provider.link_pattern)
78
+
79
+ def on_output(data: bytes) -> bytes | None:
80
+ link = scanner.feed(data)
81
+ if link is None:
82
+ return None
83
+ if no_browser:
84
+ return b"\r\n-> Open the link above in your browser.\r\n"
85
+ opened = False
86
+ try:
87
+ opened = open_browser(link)
88
+ except (webbrowser.Error, OSError): # no usable browser here
89
+ opened = False
90
+ if opened:
91
+ return b"\r\n-> Opened your browser. If it did not open, use the link above.\r\n"
92
+ return b"\r\n-> Could not open a browser here: open the link above yourself.\r\n"
93
+
94
+ return on_output
95
+
96
+
97
+ @contextlib.contextmanager
98
+ def cancel_on_signals() -> Iterator[None]:
99
+ """Treat SIGTERM and SIGHUP (terminal closed) like Ctrl+C while logging in,
100
+ so the helper container is always removed and the user sees "Cancelled."."""
101
+ if sys.platform == "win32" or threading.current_thread() is not threading.main_thread():
102
+ yield
103
+ return
104
+
105
+ def interrupt(_signum: int, _frame: object) -> None:
106
+ raise KeyboardInterrupt
107
+
108
+ previous = {sig: signal.signal(sig, interrupt) for sig in (signal.SIGTERM, signal.SIGHUP)}
109
+ try:
110
+ yield
111
+ finally:
112
+ for sig, handler in previous.items():
113
+ signal.signal(sig, handler)
114
+
115
+
116
+ def _interactive_login(
117
+ ctx: Context, provider: Provider, method: LoginMethod, image: str, args: argparse.Namespace
118
+ ) -> int:
119
+ name = f"modelmux-login-{provider.name}-{secrets.token_hex(4)}"
120
+ run_args = ctx.stack.helper_args(provider, image, method.command, ("-it", "--name", name))
121
+ try:
122
+ with cancel_on_signals():
123
+ if use_pty(args):
124
+ return ctx.docker.run_pty(
125
+ *run_args,
126
+ on_output=_link_handler(provider, ctx.console, args.no_browser),
127
+ timeout=provider.login_timeout,
128
+ )
129
+ ctx.console.step(
130
+ "Follow the instructions below; open the link they show in your browser."
131
+ )
132
+ return ctx.docker.passthrough(*run_args, timeout=provider.login_timeout)
133
+ except (TimeoutError, DockerError) as exc:
134
+ _remove(ctx, name)
135
+ if isinstance(exc, DockerError) and "in time" not in exc.message:
136
+ raise
137
+ raise CliError(
138
+ "The login timed out.", hint=f"Start again: modelmux login {provider.name}"
139
+ ) from None
140
+ except KeyboardInterrupt:
141
+ _remove(ctx, name)
142
+ raise
143
+
144
+
145
+ def _secret_login(ctx: Context, provider: Provider, method: LoginMethod, image: str) -> int:
146
+ prompt = method.secret_stdin or "Secret"
147
+ secret = read_secret(f"{prompt} (input is hidden): ").strip()
148
+ if not secret:
149
+ raise UsageError(f"No {prompt} entered.")
150
+ register_secret(secret)
151
+ run_args = ctx.stack.helper_args(provider, image, method.command, ("-i",))
152
+ return ctx.docker.run(
153
+ *run_args, input_text=secret + "\n", check=False, timeout=SECRET_LOGIN_TIMEOUT
154
+ ).returncode
155
+
156
+
157
+ def _remove(ctx: Context, container: str) -> None:
158
+ ctx.docker.run("rm", "-f", container, check=False)
159
+
160
+
161
+ def run(args: argparse.Namespace, console: Console) -> int:
162
+ ctx = make_context(console)
163
+ provider = get_provider(args.provider)
164
+ method = choose_method(provider, args.method)
165
+ image = resolve_image(ctx.config)
166
+ up_module.prepare(ctx, image)
167
+ stack = ctx.stack
168
+
169
+ if not args.force and stack.logged_in(provider, image):
170
+ console.success(
171
+ f"{provider.display_name} is already logged in (use --force to log in again)."
172
+ )
173
+ ensure_running_and_tested(ctx, provider)
174
+ return 0
175
+
176
+ console.step(f"Logging in to {provider.display_name}: {method.description}.")
177
+ if method.secret_stdin:
178
+ _secret_login(ctx, provider, method, image)
179
+ else:
180
+ _interactive_login(ctx, provider, method, image, args)
181
+ console.print()
182
+ if not stack.logged_in(provider, image):
183
+ raise CliError(
184
+ f"The {provider.display_name} login did not complete.",
185
+ hint=f"Try again: modelmux login {provider.name} (add --raw to see only the "
186
+ "provider's own output).",
187
+ )
188
+ console.success(f"Logged in to {provider.display_name}.")
189
+ ensure_running_and_tested(ctx, provider)
190
+ return 0
@@ -0,0 +1,53 @@
1
+ """``modelmux logout <provider>``: stop it and delete its saved login."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Callable
8
+
9
+ from modelmux_cli.console import Console
10
+ from modelmux_cli.context import make_context
11
+ from modelmux_cli.errors import CliError, UsageError
12
+ from modelmux_cli.providers import get_provider
13
+ from modelmux_cli.stack import resolve_image, service_name, volume_name
14
+
15
+ # Replaceable in tests.
16
+ ask: Callable[[str], str] = input
17
+
18
+
19
+ def confirm(question: str) -> bool:
20
+ if not sys.stdin.isatty():
21
+ raise UsageError(
22
+ "Logout needs confirmation.", hint="Add --yes to confirm when not in a terminal."
23
+ )
24
+ return ask(f"{question} [y/N] ").strip().lower() in {"y", "yes"}
25
+
26
+
27
+ def run(args: argparse.Namespace, console: Console) -> int:
28
+ ctx = make_context(console)
29
+ provider = get_provider(args.provider)
30
+ stack = ctx.stack
31
+ if not stack.volume_exists(provider):
32
+ console.success(f"{provider.display_name} is not logged in; nothing to remove.")
33
+ return 0
34
+ if not args.yes and not confirm(f"Remove the saved {provider.display_name} login?"):
35
+ console.step("Nothing changed.")
36
+ return 0
37
+
38
+ if stack.compose_file.exists():
39
+ stack.compose("rm", "--stop", "--force", service_name(provider), check=False)
40
+ try:
41
+ image: str | None = resolve_image(ctx.config)
42
+ except CliError:
43
+ image = None
44
+ if image is not None:
45
+ # Best effort: lets the provider revoke the token, not just forget it.
46
+ result = ctx.docker.run(
47
+ *stack.helper_args(provider, image, provider.logout_command), check=False, timeout=60
48
+ )
49
+ if not result.ok:
50
+ console.detail("The provider's own logout did not succeed; removing the login anyway.")
51
+ ctx.docker.run("volume", "rm", volume_name(provider))
52
+ console.success(f"Logged out of {provider.display_name} and removed its saved login.")
53
+ return 0
@@ -0,0 +1,157 @@
1
+ """``modelmux up``: prepare everything and start the logged-in providers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+
7
+ from modelmux_cli import health
8
+ from modelmux_cli.config import save_config
9
+ from modelmux_cli.console import Console
10
+ from modelmux_cli.context import Context, make_context
11
+ from modelmux_cli.errors import CliError, UsageError
12
+ from modelmux_cli.paths import ensure_private_dir
13
+ from modelmux_cli.ports import port_free
14
+ from modelmux_cli.providers import Provider
15
+ from modelmux_cli.secrets_store import ensure_api_key
16
+ from modelmux_cli.stack import build_compose, resolve_image, service_name, write_compose
17
+
18
+ READY_TIMEOUT = 240.0
19
+ POLL_INTERVAL = 2.0
20
+ COMPOSE_UP_TIMEOUT = 300.0
21
+
22
+
23
+ def parse_port_overrides(values: list[str], known: set[str]) -> dict[str, int]:
24
+ ports: dict[str, int] = {}
25
+ for value in values:
26
+ name, sep, raw = value.partition("=")
27
+ if not sep or name not in known or not raw.isdigit() or not 1024 <= int(raw) <= 65535:
28
+ raise UsageError(
29
+ f"Invalid --port {value!r}.",
30
+ hint="Use --port <provider>=<1024-65535>, e.g. claude=9101.",
31
+ )
32
+ ports[name] = int(raw)
33
+ return ports
34
+
35
+
36
+ def apply_settings(ctx: Context, args: argparse.Namespace) -> str:
37
+ """Validate and remember --image / --port; return the image to run."""
38
+ config = ctx.config
39
+ image = resolve_image(config, args.image)
40
+ ports = parse_port_overrides(args.port or [], set(ctx.providers))
41
+ changed = bool(ports) or (args.image is not None and args.image != config.image)
42
+ config.ports.update(ports)
43
+ if args.image is not None:
44
+ config.image = args.image
45
+ used = [config.port(name) for name in ctx.providers]
46
+ if len(used) != len(set(used)):
47
+ raise UsageError(
48
+ "Two providers are set to the same port.", hint="Choose different --port values."
49
+ )
50
+ if changed:
51
+ save_config(ctx.home, config)
52
+ return image
53
+
54
+
55
+ def start_and_wait(ctx: Context, provider: Provider) -> None:
56
+ console, stack = ctx.console, ctx.stack
57
+ service = service_name(provider)
58
+ port = ctx.config.port(provider.name)
59
+
60
+ def stopped() -> bool:
61
+ state = stack.states().get(service)
62
+ return state is not None and state.state in {"exited", "dead"}
63
+
64
+ console.step(f"Starting {provider.display_name} (it checks its login, about 10-30 s)...")
65
+ if not health.wait_until(
66
+ lambda: health.ready(port), timeout=READY_TIMEOUT, interval=POLL_INTERVAL,
67
+ should_stop=stopped,
68
+ ): # fmt: skip
69
+ if stopped():
70
+ raise CliError(
71
+ f"{provider.display_name} stopped while starting.",
72
+ hint=f"Its login may have expired: modelmux login {provider.name}. "
73
+ f"Details: modelmux logs {provider.name}",
74
+ )
75
+ raise CliError(
76
+ f"{provider.display_name} did not become ready in time.",
77
+ hint=f"See what it is doing: modelmux logs {provider.name}",
78
+ )
79
+ console.success(f"{provider.display_name} is running at {health.base_url(port)}/v1")
80
+
81
+
82
+ def prepare(ctx: Context, image: str) -> None:
83
+ """Docker, API key, compose file, image and login volumes: all idempotent."""
84
+ console, stack = ctx.console, ctx.stack
85
+ console.step("Checking Docker...")
86
+ ctx.docker.check_available()
87
+ ensure_private_dir(ctx.home)
88
+ key = ensure_api_key(ctx.home)
89
+ if key.created:
90
+ console.success("Created an API key for your clients (see it with: modelmux key show).")
91
+ write_compose(ctx.home, build_compose(ctx.providers, ctx.config, image, key.path))
92
+ if not stack.image_present(image):
93
+ console.step(f"Downloading the ModelMux server image {image} (first time only, ~1.4 GB)...")
94
+ stack.ensure_image(image)
95
+ for provider in ctx.providers.values():
96
+ stack.ensure_volume(provider)
97
+
98
+
99
+ def check_ports(ctx: Context, providers: list[Provider]) -> None:
100
+ states = ctx.stack.states()
101
+ for provider in providers:
102
+ running = states.get(service_name(provider))
103
+ port = ctx.config.port(provider.name)
104
+ if (running is None or running.state != "running") and not port_free(port):
105
+ raise CliError(
106
+ f"Port {port} for {provider.display_name} is already in use by another program.",
107
+ hint=f"Pick another port: modelmux up --port {provider.name}=<port>, "
108
+ "or run 'modelmux doctor'.",
109
+ )
110
+
111
+
112
+ def run(args: argparse.Namespace, console: Console) -> int:
113
+ ctx = make_context(console)
114
+ providers = ctx.providers
115
+ unknown = [name for name in args.providers if name not in providers]
116
+ if unknown:
117
+ raise UsageError(
118
+ f"Unknown provider {unknown[0]!r}.",
119
+ hint=f"Choose from: {', '.join(sorted(providers))}.",
120
+ )
121
+ image = apply_settings(ctx, args)
122
+ prepare(ctx, image)
123
+
124
+ targets = [providers[name] for name in (args.providers or sorted(providers))]
125
+ console.step("Checking logins...")
126
+ logged_in = {p.name: ctx.stack.logged_in(p, image) for p in targets}
127
+ missing = [p for p in targets if not logged_in[p.name]]
128
+ if args.providers and missing:
129
+ raise CliError(
130
+ f"{missing[0].display_name} is not logged in yet.",
131
+ hint=f"Log in first: modelmux login {missing[0].name}",
132
+ )
133
+ to_start = [p for p in targets if logged_in[p.name]]
134
+ if not to_start:
135
+ console.success("ModelMux is set up. No provider is logged in yet.")
136
+ console.step(f"Next: modelmux login {targets[0].name}")
137
+ return 0
138
+
139
+ check_ports(ctx, to_start)
140
+ states = ctx.stack.states()
141
+ was_running = {
142
+ p.name for p in to_start
143
+ if (s := states.get(service_name(p))) is not None and s.state == "running"
144
+ } # fmt: skip
145
+ ctx.stack.compose("up", "-d", *(service_name(p) for p in to_start), timeout=COMPOSE_UP_TIMEOUT)
146
+ for provider in to_start:
147
+ port = ctx.config.port(provider.name)
148
+ if provider.name in was_running and health.ready(port):
149
+ console.success(
150
+ f"{provider.display_name} is already running at {health.base_url(port)}/v1"
151
+ )
152
+ else:
153
+ start_and_wait(ctx, provider)
154
+ for provider in missing:
155
+ console.step(f"{provider.display_name} is not logged in: modelmux login {provider.name}")
156
+ console.step("Configure your tools: modelmux config litellm (or openai-python, langchain)")
157
+ return 0
@@ -0,0 +1,50 @@
1
+ """``modelmux upgrade``: run the server image that matches this CLI, keeping logins.
2
+
3
+ The image version follows the CLI version: a newer server comes with a newer
4
+ modelmux-cli. This command makes sure the image pinned for the installed CLI
5
+ (or an explicit ``--image``) is present, recreates the providers that were
6
+ running, and says when a newer CLI is available.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+
13
+ from modelmux_cli import __version__, updates
14
+ from modelmux_cli.commands import up as up_module
15
+ from modelmux_cli.config import save_config
16
+ from modelmux_cli.console import Console
17
+ from modelmux_cli.context import make_context
18
+ from modelmux_cli.stack import resolve_image, service_name
19
+
20
+
21
+ def run(args: argparse.Namespace, console: Console) -> int:
22
+ ctx = make_context(console)
23
+ image = resolve_image(ctx.config, args.image)
24
+ if args.image is not None and args.image != ctx.config.image:
25
+ ctx.config.image = args.image
26
+ save_config(ctx.home, ctx.config)
27
+
28
+ states = ctx.stack.states() if ctx.stack.compose_file.exists() else {}
29
+ up_module.prepare(ctx, image) # downloads the image if needed, rewrites compose.yaml
30
+ console.success(f"Server image: {image}")
31
+
32
+ running = [
33
+ p for p in sorted(ctx.providers.values(), key=lambda p: p.name)
34
+ if (s := states.get(service_name(p))) is not None and s.state == "running"
35
+ ] # fmt: skip
36
+ if running:
37
+ ctx.stack.compose(
38
+ "up", "-d", *(service_name(p) for p in running), timeout=up_module.COMPOSE_UP_TIMEOUT
39
+ )
40
+ for provider in running:
41
+ up_module.start_and_wait(ctx, provider)
42
+ console.success("Upgraded. Logins were kept.")
43
+ else:
44
+ console.success("Nothing was running; the next 'modelmux up' uses this image.")
45
+
46
+ latest = updates.latest_version()
47
+ if updates.newer_available(__version__, latest):
48
+ console.step(f"modelmux-cli {latest} is available (you have {__version__}). "
49
+ f"It comes with a newer server: {updates.UPGRADE_HINT}") # fmt: skip
50
+ return 0
modelmux_cli/config.py ADDED
@@ -0,0 +1,78 @@
1
+ """The CLI's settings (``config.json``): ports, and an optional image override.
2
+
3
+ Everything has a default, so the file only exists once something is changed
4
+ or ``modelmux up`` runs. A damaged file is reported, never silently replaced.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from modelmux_cli.errors import CliError
15
+ from modelmux_cli.files import write_private
16
+ from modelmux_cli.paths import ensure_private_dir
17
+ from modelmux_cli.providers import load_providers
18
+
19
+ CONFIG_FILE = "config.json"
20
+ CONFIG_VERSION = 1
21
+
22
+
23
+ @dataclass
24
+ class Config:
25
+ ports: dict[str, int] = field(default_factory=dict)
26
+ image: str | None = None # explicit override; None means "the image pinned for this CLI"
27
+
28
+ def port(self, provider: str) -> int:
29
+ if provider in self.ports:
30
+ return self.ports[provider]
31
+ return load_providers()[provider].default_port
32
+
33
+
34
+ class ConfigFileError(CliError):
35
+ def __init__(self, path: Path, problem: str) -> None:
36
+ super().__init__(
37
+ f"Your ModelMux settings file is damaged ({problem}).",
38
+ hint=f"Fix or delete {path} and run the command again.",
39
+ )
40
+
41
+
42
+ def _parse(path: Path, data: Any) -> Config:
43
+ if not isinstance(data, dict) or data.get("version") != CONFIG_VERSION:
44
+ raise ConfigFileError(path, "unknown format version")
45
+ ports = data.get("ports", {})
46
+ if not isinstance(ports, dict):
47
+ raise ConfigFileError(path, "'ports' must be an object")
48
+ known = load_providers()
49
+ for name, port in ports.items():
50
+ if name not in known:
51
+ raise ConfigFileError(path, f"unknown provider {name!r} in 'ports'")
52
+ if not isinstance(port, int) or isinstance(port, bool) or not 1 <= port <= 65535:
53
+ raise ConfigFileError(path, f"invalid port for {name!r}")
54
+ image = data.get("image")
55
+ if image is not None and not isinstance(image, str):
56
+ raise ConfigFileError(path, "'image' must be a string")
57
+ return Config(ports=dict(ports), image=image)
58
+
59
+
60
+ def load_config(directory: Path) -> Config:
61
+ path = directory / CONFIG_FILE
62
+ if not path.exists():
63
+ return Config()
64
+ try:
65
+ data = json.loads(path.read_text(encoding="utf-8"))
66
+ except (OSError, ValueError):
67
+ raise ConfigFileError(path, "not valid JSON") from None
68
+ return _parse(path, data)
69
+
70
+
71
+ def save_config(directory: Path, config: Config) -> Path:
72
+ ensure_private_dir(directory)
73
+ path = directory / CONFIG_FILE
74
+ data: dict[str, Any] = {"version": CONFIG_VERSION, "ports": dict(sorted(config.ports.items()))}
75
+ if config.image is not None:
76
+ data["image"] = config.image
77
+ write_private(path, json.dumps(data, indent=2) + "\n")
78
+ return path
@@ -0,0 +1,112 @@
1
+ """Terminal output: short, friendly lines with ✓ / ✗ markers.
2
+
3
+ Colour is used only on a real terminal, never when ``NO_COLOR`` is set or
4
+ ``TERM=dumb``. Symbols fall back to ASCII when the terminal encoding cannot
5
+ show them (older Windows consoles).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ from typing import TextIO
13
+
14
+ from modelmux_cli.redact import redact
15
+
16
+ _RESET = "\033[0m"
17
+ _STYLES = {"ok": "\033[32m", "error": "\033[31m", "warn": "\033[33m", "dim": "\033[2m",
18
+ "bold": "\033[1m"} # fmt: skip
19
+ _SYMBOLS = {"ok": ("✓", "OK"), "error": ("✗", "X"), "warn": ("!", "!"), "step": ("→", ">")}
20
+
21
+
22
+ def _enable_windows_ansi() -> bool:
23
+ """Turn on ANSI escape handling in the Windows console (Windows 10+)."""
24
+ if sys.platform != "win32":
25
+ return True
26
+ try:
27
+ import ctypes # noqa: PLC0415 - Windows only
28
+
29
+ kernel32 = ctypes.windll.kernel32
30
+ handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
31
+ mode = ctypes.c_uint32()
32
+ if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
33
+ return False
34
+ return bool(kernel32.SetConsoleMode(handle, mode.value | 0x0004))
35
+ except (AttributeError, OSError):
36
+ return False
37
+
38
+
39
+ def _can_encode(stream: TextIO, text: str) -> bool:
40
+ try:
41
+ text.encode(stream.encoding or "ascii")
42
+ except (UnicodeEncodeError, LookupError):
43
+ return False
44
+ return True
45
+
46
+
47
+ class Console:
48
+ def __init__(
49
+ self,
50
+ out: TextIO | None = None,
51
+ err: TextIO | None = None,
52
+ *,
53
+ verbose: bool = False,
54
+ color: bool | None = None,
55
+ ) -> None:
56
+ self.out = out or sys.stdout
57
+ self.err = err or sys.stderr
58
+ self.verbose = verbose
59
+ if color is None:
60
+ color = (
61
+ self.out.isatty()
62
+ and "NO_COLOR" not in os.environ
63
+ and os.environ.get("TERM") != "dumb"
64
+ and _enable_windows_ansi()
65
+ )
66
+ self.color = color
67
+ self.unicode = _can_encode(self.out, "✓✗→")
68
+
69
+ def _style(self, kind: str, text: str) -> str:
70
+ return f"{_STYLES[kind]}{text}{_RESET}" if self.color else text
71
+
72
+ def _symbol(self, kind: str) -> str:
73
+ fancy, plain = _SYMBOLS[kind]
74
+ return fancy if self.unicode else plain
75
+
76
+ def _line(self, stream: TextIO, text: str) -> None:
77
+ stream.write(redact(text) + "\n")
78
+ stream.flush()
79
+
80
+ # ------------------------------------------------------------ public API
81
+
82
+ def print(self, text: str = "") -> None:
83
+ """Plain output (command results, config snippets)."""
84
+ self._line(self.out, text)
85
+
86
+ def success(self, text: str) -> None:
87
+ self._line(self.out, f"{self._style('ok', self._symbol('ok'))} {text}")
88
+
89
+ def step(self, text: str) -> None:
90
+ self._line(self.out, f"{self._style('dim', self._symbol('step'))} {text}")
91
+
92
+ def warn(self, text: str) -> None:
93
+ self._line(self.err, f"{self._style('warn', self._symbol('warn'))} {text}")
94
+
95
+ def error(self, text: str, hint: str | None = None) -> None:
96
+ self._line(self.err, f"{self._style('error', self._symbol('error'))} {text}")
97
+ if hint:
98
+ self._line(self.err, f" {self._style('dim', hint)}")
99
+
100
+ def note(self, text: str) -> None:
101
+ """A hint on stderr, so stdout stays clean for piping (``config > file``)."""
102
+ self._line(self.err, f"{self._style('dim', self._symbol('step'))} {text}")
103
+
104
+ def reveal(self, text: str) -> None:
105
+ """Print WITHOUT redaction. Only for an explicit user request to see a secret."""
106
+ self.out.write(text + "\n")
107
+ self.out.flush()
108
+
109
+ def detail(self, text: str) -> None:
110
+ """Only shown with --verbose."""
111
+ if self.verbose:
112
+ self._line(self.err, self._style("dim", f" {text}"))
@@ -0,0 +1,41 @@
1
+ """Everything a command needs, built in one place (and replaceable in tests)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from functools import cached_property
7
+ from pathlib import Path
8
+
9
+ from modelmux_cli.config import Config, load_config
10
+ from modelmux_cli.console import Console
11
+ from modelmux_cli.docker import Docker
12
+ from modelmux_cli.paths import config_dir
13
+ from modelmux_cli.providers import Provider, load_providers
14
+ from modelmux_cli.stack import Stack
15
+
16
+
17
+ def make_docker(console: Console) -> Docker:
18
+ return Docker(console)
19
+
20
+
21
+ @dataclass
22
+ class Context:
23
+ console: Console
24
+ docker: Docker
25
+ home: Path = field(default_factory=config_dir)
26
+
27
+ @cached_property
28
+ def config(self) -> Config:
29
+ return load_config(self.home)
30
+
31
+ @cached_property
32
+ def providers(self) -> dict[str, Provider]:
33
+ return load_providers()
34
+
35
+ @cached_property
36
+ def stack(self) -> Stack:
37
+ return Stack(self.docker, self.home)
38
+
39
+
40
+ def make_context(console: Console) -> Context:
41
+ return Context(console, make_docker(console))