mlx-stack 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.
- mlx_stack/__init__.py +5 -0
- mlx_stack/_version.py +24 -0
- mlx_stack/cli/__init__.py +5 -0
- mlx_stack/cli/bench.py +221 -0
- mlx_stack/cli/config.py +166 -0
- mlx_stack/cli/down.py +109 -0
- mlx_stack/cli/init.py +180 -0
- mlx_stack/cli/install.py +165 -0
- mlx_stack/cli/logs.py +234 -0
- mlx_stack/cli/main.py +187 -0
- mlx_stack/cli/models.py +304 -0
- mlx_stack/cli/profile.py +65 -0
- mlx_stack/cli/pull.py +134 -0
- mlx_stack/cli/recommend.py +397 -0
- mlx_stack/cli/status.py +111 -0
- mlx_stack/cli/up.py +163 -0
- mlx_stack/cli/watch.py +252 -0
- mlx_stack/core/__init__.py +1 -0
- mlx_stack/core/benchmark.py +1182 -0
- mlx_stack/core/catalog.py +560 -0
- mlx_stack/core/config.py +471 -0
- mlx_stack/core/deps.py +323 -0
- mlx_stack/core/hardware.py +304 -0
- mlx_stack/core/launchd.py +531 -0
- mlx_stack/core/litellm_gen.py +188 -0
- mlx_stack/core/log_rotation.py +231 -0
- mlx_stack/core/log_viewer.py +386 -0
- mlx_stack/core/models.py +639 -0
- mlx_stack/core/paths.py +79 -0
- mlx_stack/core/process.py +887 -0
- mlx_stack/core/pull.py +815 -0
- mlx_stack/core/scoring.py +611 -0
- mlx_stack/core/stack_down.py +317 -0
- mlx_stack/core/stack_init.py +524 -0
- mlx_stack/core/stack_status.py +229 -0
- mlx_stack/core/stack_up.py +856 -0
- mlx_stack/core/watchdog.py +744 -0
- mlx_stack/data/__init__.py +1 -0
- mlx_stack/data/catalog/__init__.py +1 -0
- mlx_stack/data/catalog/deepseek-r1-32b.yaml +46 -0
- mlx_stack/data/catalog/deepseek-r1-8b.yaml +45 -0
- mlx_stack/data/catalog/gemma3-12b.yaml +45 -0
- mlx_stack/data/catalog/gemma3-27b.yaml +45 -0
- mlx_stack/data/catalog/gemma3-4b.yaml +45 -0
- mlx_stack/data/catalog/llama3.3-8b.yaml +44 -0
- mlx_stack/data/catalog/nemotron-49b.yaml +41 -0
- mlx_stack/data/catalog/nemotron-8b.yaml +44 -0
- mlx_stack/data/catalog/qwen3-8b.yaml +45 -0
- mlx_stack/data/catalog/qwen3.5-0.8b.yaml +45 -0
- mlx_stack/data/catalog/qwen3.5-14b.yaml +46 -0
- mlx_stack/data/catalog/qwen3.5-32b.yaml +45 -0
- mlx_stack/data/catalog/qwen3.5-3b.yaml +44 -0
- mlx_stack/data/catalog/qwen3.5-72b.yaml +42 -0
- mlx_stack/data/catalog/qwen3.5-8b.yaml +45 -0
- mlx_stack/py.typed +1 -0
- mlx_stack/utils/__init__.py +1 -0
- mlx_stack-0.1.0.dist-info/METADATA +397 -0
- mlx_stack-0.1.0.dist-info/RECORD +61 -0
- mlx_stack-0.1.0.dist-info/WHEEL +4 -0
- mlx_stack-0.1.0.dist-info/entry_points.txt +2 -0
- mlx_stack-0.1.0.dist-info/licenses/LICENSE +21 -0
mlx_stack/cli/init.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
"""CLI command for stack initialization — `mlx-stack init`.
|
|
2
|
+
|
|
3
|
+
Generates stack definition and LiteLLM configuration files from a
|
|
4
|
+
hardware profile and recommendation. Supports --accept-defaults for
|
|
5
|
+
non-interactive mode, --intent, --add/--remove for customization,
|
|
6
|
+
and --force for overwriting existing configs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from mlx_stack.core.stack_init import InitError, run_init
|
|
17
|
+
|
|
18
|
+
console = Console(stderr=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _display_summary(result: dict) -> None:
|
|
22
|
+
"""Display a summary of the generated configuration.
|
|
23
|
+
|
|
24
|
+
Shows file paths, tier assignments, total estimated memory,
|
|
25
|
+
and next-step instructions.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
result: The result dict from run_init.
|
|
29
|
+
"""
|
|
30
|
+
out = Console()
|
|
31
|
+
stack = result["stack"]
|
|
32
|
+
profile = result["profile"]
|
|
33
|
+
|
|
34
|
+
out.print()
|
|
35
|
+
out.print(Text("✅ Stack initialized successfully!", style="bold green"))
|
|
36
|
+
out.print()
|
|
37
|
+
|
|
38
|
+
# File paths
|
|
39
|
+
out.print(Text("Generated files:", style="bold"))
|
|
40
|
+
out.print(f" Stack: {result['stack_path']}")
|
|
41
|
+
out.print(f" LiteLLM: {result['litellm_path']}")
|
|
42
|
+
out.print()
|
|
43
|
+
|
|
44
|
+
# Tier assignments table
|
|
45
|
+
out.print(Text("Tier assignments:", style="bold"))
|
|
46
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
47
|
+
table.add_column("Tier", style="bold", min_width=12)
|
|
48
|
+
table.add_column("Model", min_width=20)
|
|
49
|
+
table.add_column("Quant", min_width=6)
|
|
50
|
+
table.add_column("Port", justify="right", min_width=6)
|
|
51
|
+
|
|
52
|
+
for tier in stack["tiers"]:
|
|
53
|
+
table.add_row(
|
|
54
|
+
tier["name"],
|
|
55
|
+
tier["model"],
|
|
56
|
+
tier["quant"],
|
|
57
|
+
str(tier["port"]),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
out.print(table)
|
|
61
|
+
|
|
62
|
+
# Hardware and memory summary
|
|
63
|
+
out.print()
|
|
64
|
+
budget_gb = result["memory_budget_gb"]
|
|
65
|
+
total_memory_gb = result.get("total_memory_gb", 0.0)
|
|
66
|
+
out.print(
|
|
67
|
+
f"[dim]Hardware: {profile.chip} ({profile.memory_gb} GB) · "
|
|
68
|
+
f"Budget: {budget_gb:.1f} GB[/dim]"
|
|
69
|
+
)
|
|
70
|
+
if total_memory_gb > 0:
|
|
71
|
+
out.print(
|
|
72
|
+
f"[dim]Total estimated memory: {total_memory_gb:.1f} GB[/dim]"
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
# Warnings (e.g., memory budget exceeded with --add)
|
|
76
|
+
init_warnings = result.get("warnings", [])
|
|
77
|
+
if init_warnings:
|
|
78
|
+
out.print()
|
|
79
|
+
for warning in init_warnings:
|
|
80
|
+
out.print(f"[yellow]⚠ {warning}[/yellow]")
|
|
81
|
+
|
|
82
|
+
# Cloud fallback indicator
|
|
83
|
+
if stack.get("cloud_fallback"):
|
|
84
|
+
out.print()
|
|
85
|
+
out.print(
|
|
86
|
+
"[bold green]☁ Cloud Fallback[/bold green] "
|
|
87
|
+
"Premium tier via OpenRouter configured"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Missing models warning
|
|
91
|
+
missing = result.get("missing_models", [])
|
|
92
|
+
if missing:
|
|
93
|
+
out.print()
|
|
94
|
+
out.print("[yellow]⚠ Missing local models:[/yellow]")
|
|
95
|
+
for model_id in missing:
|
|
96
|
+
out.print(f" • {model_id}")
|
|
97
|
+
out.print()
|
|
98
|
+
out.print(
|
|
99
|
+
" Run [bold]mlx-stack pull[/bold] to download missing models "
|
|
100
|
+
"before starting the stack."
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Next steps
|
|
104
|
+
out.print()
|
|
105
|
+
out.print(Text("Next steps:", style="bold"))
|
|
106
|
+
if missing:
|
|
107
|
+
out.print(" 1. [bold]mlx-stack pull[/bold] — Download missing models")
|
|
108
|
+
out.print(" 2. [bold]mlx-stack up[/bold] — Start all services")
|
|
109
|
+
else:
|
|
110
|
+
out.print(" 1. [bold]mlx-stack up[/bold] — Start all services")
|
|
111
|
+
out.print()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@click.command()
|
|
115
|
+
@click.option(
|
|
116
|
+
"--accept-defaults",
|
|
117
|
+
is_flag=True,
|
|
118
|
+
default=False,
|
|
119
|
+
help="Use defaults without prompting (balanced intent, default budget).",
|
|
120
|
+
)
|
|
121
|
+
@click.option(
|
|
122
|
+
"--intent",
|
|
123
|
+
type=str,
|
|
124
|
+
default=None,
|
|
125
|
+
help="Recommendation intent: balanced (default) or agent-fleet.",
|
|
126
|
+
)
|
|
127
|
+
@click.option(
|
|
128
|
+
"--add",
|
|
129
|
+
"add_models",
|
|
130
|
+
multiple=True,
|
|
131
|
+
help="Add a model to the stack (can be specified multiple times).",
|
|
132
|
+
)
|
|
133
|
+
@click.option(
|
|
134
|
+
"--remove",
|
|
135
|
+
"remove_tiers",
|
|
136
|
+
multiple=True,
|
|
137
|
+
help="Remove a tier from the stack (can be specified multiple times).",
|
|
138
|
+
)
|
|
139
|
+
@click.option(
|
|
140
|
+
"--force",
|
|
141
|
+
is_flag=True,
|
|
142
|
+
default=False,
|
|
143
|
+
help="Overwrite existing stack configuration.",
|
|
144
|
+
)
|
|
145
|
+
def init(
|
|
146
|
+
accept_defaults: bool,
|
|
147
|
+
intent: str | None,
|
|
148
|
+
add_models: tuple[str, ...],
|
|
149
|
+
remove_tiers: tuple[str, ...],
|
|
150
|
+
force: bool,
|
|
151
|
+
) -> None:
|
|
152
|
+
"""Generate stack definition and LiteLLM config.
|
|
153
|
+
|
|
154
|
+
Creates ~/.mlx-stack/stacks/default.yaml with tier assignments
|
|
155
|
+
and ~/.mlx-stack/litellm.yaml with proxy configuration.
|
|
156
|
+
|
|
157
|
+
Use --accept-defaults for non-interactive mode. Combine with
|
|
158
|
+
--intent to specify the optimization strategy.
|
|
159
|
+
|
|
160
|
+
Use --add to include additional models and --remove to exclude
|
|
161
|
+
specific tiers from the default recommendation.
|
|
162
|
+
|
|
163
|
+
Requires --force to overwrite an existing stack configuration.
|
|
164
|
+
"""
|
|
165
|
+
# Default intent
|
|
166
|
+
if intent is None:
|
|
167
|
+
intent = "balanced"
|
|
168
|
+
|
|
169
|
+
try:
|
|
170
|
+
result = run_init(
|
|
171
|
+
intent=intent,
|
|
172
|
+
add_models=list(add_models) if add_models else None,
|
|
173
|
+
remove_tiers=list(remove_tiers) if remove_tiers else None,
|
|
174
|
+
force=force,
|
|
175
|
+
)
|
|
176
|
+
except InitError as exc:
|
|
177
|
+
console.print(f"[bold red]Error:[/bold red] {exc}")
|
|
178
|
+
raise SystemExit(1) from None
|
|
179
|
+
|
|
180
|
+
_display_summary(result)
|
mlx_stack/cli/install.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""CLI commands for launchd integration — `mlx-stack install` and `mlx-stack uninstall`.
|
|
2
|
+
|
|
3
|
+
Manages the watchdog as a macOS LaunchAgent, enabling automatic startup
|
|
4
|
+
on login and persistent health monitoring.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
|
|
15
|
+
from mlx_stack.core.launchd import (
|
|
16
|
+
AgentStatus,
|
|
17
|
+
LaunchdError,
|
|
18
|
+
PlatformError,
|
|
19
|
+
PrerequisiteError,
|
|
20
|
+
check_platform,
|
|
21
|
+
get_agent_status,
|
|
22
|
+
install_agent,
|
|
23
|
+
uninstall_agent,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
console = Console(stderr=True)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# --------------------------------------------------------------------------- #
|
|
30
|
+
# Status display helper
|
|
31
|
+
# --------------------------------------------------------------------------- #
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _display_status(status: AgentStatus) -> None:
|
|
35
|
+
"""Display the agent status in Rich format.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
status: The current agent status.
|
|
39
|
+
"""
|
|
40
|
+
out = Console()
|
|
41
|
+
|
|
42
|
+
if not status.installed:
|
|
43
|
+
out.print(Text("Status: not installed", style="dim"))
|
|
44
|
+
elif status.running and status.pid is not None:
|
|
45
|
+
out.print(
|
|
46
|
+
Text(f"Status: installed and running (PID {status.pid})", style="green")
|
|
47
|
+
)
|
|
48
|
+
else:
|
|
49
|
+
out.print(Text("Status: installed but not running", style="yellow"))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --------------------------------------------------------------------------- #
|
|
53
|
+
# install command
|
|
54
|
+
# --------------------------------------------------------------------------- #
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@click.command()
|
|
58
|
+
@click.option(
|
|
59
|
+
"--status",
|
|
60
|
+
"show_status",
|
|
61
|
+
is_flag=True,
|
|
62
|
+
default=False,
|
|
63
|
+
help="Show the current launchd agent status without installing.",
|
|
64
|
+
)
|
|
65
|
+
def install(show_status: bool) -> None:
|
|
66
|
+
"""Install the watchdog as a macOS LaunchAgent.
|
|
67
|
+
|
|
68
|
+
Generates a launchd plist that runs `mlx-stack watch` automatically
|
|
69
|
+
on login. The watchdog monitors stack health and auto-restarts
|
|
70
|
+
crashed services.
|
|
71
|
+
|
|
72
|
+
\b
|
|
73
|
+
Requires:
|
|
74
|
+
• macOS (launchd is macOS-only)
|
|
75
|
+
• mlx-stack init (stack must be configured first)
|
|
76
|
+
|
|
77
|
+
\b
|
|
78
|
+
Behavior:
|
|
79
|
+
• Creates plist at ~/Library/LaunchAgents/com.mlx-stack.watchdog.plist
|
|
80
|
+
• Loads the agent immediately via launchctl bootstrap
|
|
81
|
+
• If already installed, updates the plist and reloads
|
|
82
|
+
|
|
83
|
+
\b
|
|
84
|
+
Examples:
|
|
85
|
+
mlx-stack install Install and start the watchdog agent
|
|
86
|
+
mlx-stack install --status Check if the agent is installed
|
|
87
|
+
"""
|
|
88
|
+
# Platform guard: reject non-macOS before any branch (including --status)
|
|
89
|
+
try:
|
|
90
|
+
check_platform()
|
|
91
|
+
except PlatformError as exc:
|
|
92
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
93
|
+
sys.exit(1)
|
|
94
|
+
|
|
95
|
+
if show_status:
|
|
96
|
+
status = get_agent_status()
|
|
97
|
+
_display_status(status)
|
|
98
|
+
return
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
plist_path, was_reinstall = install_agent()
|
|
102
|
+
|
|
103
|
+
out = Console()
|
|
104
|
+
if was_reinstall:
|
|
105
|
+
out.print(Text("Watchdog agent updated and reloaded.", style="bold green"))
|
|
106
|
+
else:
|
|
107
|
+
out.print(Text("Watchdog agent installed successfully.", style="bold green"))
|
|
108
|
+
|
|
109
|
+
out.print(Text(f" Plist: {plist_path}", style="dim"))
|
|
110
|
+
out.print(
|
|
111
|
+
Text(
|
|
112
|
+
" The watchdog will start automatically on login.",
|
|
113
|
+
style="dim",
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
except PlatformError as exc:
|
|
118
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
119
|
+
sys.exit(1)
|
|
120
|
+
except PrerequisiteError as exc:
|
|
121
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
122
|
+
sys.exit(1)
|
|
123
|
+
except LaunchdError as exc:
|
|
124
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
125
|
+
sys.exit(1)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --------------------------------------------------------------------------- #
|
|
129
|
+
# uninstall command
|
|
130
|
+
# --------------------------------------------------------------------------- #
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@click.command()
|
|
134
|
+
def uninstall() -> None:
|
|
135
|
+
"""Uninstall the watchdog LaunchAgent.
|
|
136
|
+
|
|
137
|
+
Stops the watchdog agent and removes the launchd plist. Running
|
|
138
|
+
services are not affected — only the watchdog auto-monitoring
|
|
139
|
+
is disabled.
|
|
140
|
+
|
|
141
|
+
\b
|
|
142
|
+
Examples:
|
|
143
|
+
mlx-stack uninstall Remove the watchdog agent
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
removed = uninstall_agent()
|
|
147
|
+
|
|
148
|
+
out = Console()
|
|
149
|
+
if removed:
|
|
150
|
+
out.print(Text("Watchdog agent uninstalled.", style="bold green"))
|
|
151
|
+
out.print(
|
|
152
|
+
Text(
|
|
153
|
+
" Running services are not affected.",
|
|
154
|
+
style="dim",
|
|
155
|
+
)
|
|
156
|
+
)
|
|
157
|
+
else:
|
|
158
|
+
out.print(Text("Watchdog agent is not installed.", style="yellow"))
|
|
159
|
+
|
|
160
|
+
except PlatformError as exc:
|
|
161
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
162
|
+
sys.exit(1)
|
|
163
|
+
except LaunchdError as exc:
|
|
164
|
+
console.print(f"[red]Error:[/red] {exc}")
|
|
165
|
+
sys.exit(1)
|
mlx_stack/cli/logs.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""CLI command for viewing and managing service logs — `mlx-stack logs`.
|
|
2
|
+
|
|
3
|
+
View log content, follow in real-time, list available log files,
|
|
4
|
+
trigger on-demand rotation, and view archived logs.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
from rich.text import Text
|
|
15
|
+
|
|
16
|
+
from mlx_stack.core.log_viewer import (
|
|
17
|
+
DEFAULT_TAIL_LINES,
|
|
18
|
+
follow_log,
|
|
19
|
+
get_available_services,
|
|
20
|
+
get_log_path,
|
|
21
|
+
list_log_files,
|
|
22
|
+
read_all_logs,
|
|
23
|
+
read_log_tail,
|
|
24
|
+
rotate_all_logs,
|
|
25
|
+
rotate_service_log,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
console = Console(stderr=True)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _display_log_list() -> None:
|
|
32
|
+
"""Display a Rich table listing all available log files."""
|
|
33
|
+
log_files = list_log_files()
|
|
34
|
+
|
|
35
|
+
out = Console()
|
|
36
|
+
out.print()
|
|
37
|
+
|
|
38
|
+
if not log_files:
|
|
39
|
+
out.print(
|
|
40
|
+
Text(
|
|
41
|
+
"No log files found in logs directory.",
|
|
42
|
+
style="yellow",
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
out.print()
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
table = Table(
|
|
49
|
+
title="Available Log Files",
|
|
50
|
+
show_header=True,
|
|
51
|
+
header_style="bold cyan",
|
|
52
|
+
)
|
|
53
|
+
table.add_column("Name", style="bold", min_width=20)
|
|
54
|
+
table.add_column("Size", justify="right", min_width=10)
|
|
55
|
+
table.add_column("Modified", min_width=20)
|
|
56
|
+
|
|
57
|
+
for info in log_files:
|
|
58
|
+
table.add_row(info.name, info.size_display, info.modified_display)
|
|
59
|
+
|
|
60
|
+
out.print(table)
|
|
61
|
+
out.print()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _display_rotation_results(results: list) -> None:
|
|
65
|
+
"""Display rotation results with Rich formatting.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
results: List of RotationResult objects.
|
|
69
|
+
"""
|
|
70
|
+
out = Console()
|
|
71
|
+
out.print()
|
|
72
|
+
|
|
73
|
+
any_rotated = False
|
|
74
|
+
for result in results:
|
|
75
|
+
if result.error:
|
|
76
|
+
out.print(f"[red]✗[/red] {result.service}: {result.error}")
|
|
77
|
+
elif result.rotated:
|
|
78
|
+
out.print(f"[green]✓[/green] {result.service}: rotated")
|
|
79
|
+
any_rotated = True
|
|
80
|
+
else:
|
|
81
|
+
out.print(f"[dim]–[/dim] {result.service}: no rotation needed")
|
|
82
|
+
|
|
83
|
+
if not results:
|
|
84
|
+
out.print(Text("No log files found to rotate.", style="yellow"))
|
|
85
|
+
elif not any_rotated and not any(r.error for r in results):
|
|
86
|
+
out.print()
|
|
87
|
+
out.print(Text("No rotation needed.", style="dim"))
|
|
88
|
+
|
|
89
|
+
out.print()
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@click.command()
|
|
93
|
+
@click.argument("service", required=False, default=None)
|
|
94
|
+
@click.option(
|
|
95
|
+
"--tail",
|
|
96
|
+
"tail_lines",
|
|
97
|
+
type=int,
|
|
98
|
+
default=None,
|
|
99
|
+
help=f"Show last N lines (default {DEFAULT_TAIL_LINES}).",
|
|
100
|
+
)
|
|
101
|
+
@click.option(
|
|
102
|
+
"--follow",
|
|
103
|
+
"-f",
|
|
104
|
+
is_flag=True,
|
|
105
|
+
default=False,
|
|
106
|
+
help="Follow log output in real-time (tail -f behavior).",
|
|
107
|
+
)
|
|
108
|
+
@click.option(
|
|
109
|
+
"--service",
|
|
110
|
+
"service_filter",
|
|
111
|
+
type=str,
|
|
112
|
+
default=None,
|
|
113
|
+
help="Filter to a specific service's log.",
|
|
114
|
+
)
|
|
115
|
+
@click.option(
|
|
116
|
+
"--rotate",
|
|
117
|
+
is_flag=True,
|
|
118
|
+
default=False,
|
|
119
|
+
help="Rotate eligible log files.",
|
|
120
|
+
)
|
|
121
|
+
@click.option(
|
|
122
|
+
"--all",
|
|
123
|
+
"show_all",
|
|
124
|
+
is_flag=True,
|
|
125
|
+
default=False,
|
|
126
|
+
help="Show archived and current logs in chronological order.",
|
|
127
|
+
)
|
|
128
|
+
def logs(
|
|
129
|
+
service: str | None,
|
|
130
|
+
tail_lines: int | None,
|
|
131
|
+
follow: bool,
|
|
132
|
+
service_filter: str | None,
|
|
133
|
+
rotate: bool,
|
|
134
|
+
show_all: bool,
|
|
135
|
+
) -> None:
|
|
136
|
+
"""View and manage service logs.
|
|
137
|
+
|
|
138
|
+
Without arguments, lists all available log files with sizes and
|
|
139
|
+
modification times.
|
|
140
|
+
|
|
141
|
+
\b
|
|
142
|
+
Examples:
|
|
143
|
+
mlx-stack logs List all log files
|
|
144
|
+
mlx-stack logs fast Show last 50 lines of fast.log
|
|
145
|
+
mlx-stack logs fast --tail 100 Show last 100 lines
|
|
146
|
+
mlx-stack logs fast --follow Stream new log content in real-time
|
|
147
|
+
mlx-stack logs --rotate Rotate all eligible log files
|
|
148
|
+
mlx-stack logs fast --all Show archived + current logs
|
|
149
|
+
"""
|
|
150
|
+
# Resolve effective service name: argument takes precedence over --service
|
|
151
|
+
effective_service = service or service_filter
|
|
152
|
+
|
|
153
|
+
# Handle --rotate mode
|
|
154
|
+
if rotate:
|
|
155
|
+
if effective_service:
|
|
156
|
+
# Rotate only the specified service
|
|
157
|
+
available = get_available_services()
|
|
158
|
+
if available and effective_service not in available:
|
|
159
|
+
console.print(
|
|
160
|
+
f"[red]Error:[/red] No log file found for service "
|
|
161
|
+
f"'{effective_service}'. "
|
|
162
|
+
f"Available services: {', '.join(available)}"
|
|
163
|
+
)
|
|
164
|
+
sys.exit(1)
|
|
165
|
+
|
|
166
|
+
result = rotate_service_log(effective_service)
|
|
167
|
+
_display_rotation_results([result])
|
|
168
|
+
else:
|
|
169
|
+
# Rotate all eligible logs
|
|
170
|
+
results = rotate_all_logs()
|
|
171
|
+
_display_rotation_results(results)
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
# No service specified — list log files
|
|
175
|
+
if effective_service is None:
|
|
176
|
+
_display_log_list()
|
|
177
|
+
return
|
|
178
|
+
|
|
179
|
+
# Handle --all mode: show archives + current.
|
|
180
|
+
# This must be checked BEFORE the log_path validation because
|
|
181
|
+
# read_all_logs works even when the current log file is missing
|
|
182
|
+
# (it can show archives only).
|
|
183
|
+
if show_all:
|
|
184
|
+
content = read_all_logs(effective_service)
|
|
185
|
+
if content:
|
|
186
|
+
click.echo(content)
|
|
187
|
+
else:
|
|
188
|
+
console.print(
|
|
189
|
+
Text(
|
|
190
|
+
f"No log content found for service '{effective_service}'.",
|
|
191
|
+
style="yellow",
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
# Validate service name — required for --follow, --tail, and default modes
|
|
197
|
+
log_path = get_log_path(effective_service)
|
|
198
|
+
if log_path is None:
|
|
199
|
+
available = get_available_services()
|
|
200
|
+
if available:
|
|
201
|
+
console.print(
|
|
202
|
+
f"[red]Error:[/red] No log file found for service "
|
|
203
|
+
f"'{effective_service}'. "
|
|
204
|
+
f"Available services: {', '.join(available)}"
|
|
205
|
+
)
|
|
206
|
+
else:
|
|
207
|
+
console.print(
|
|
208
|
+
f"[red]Error:[/red] No log file found for service "
|
|
209
|
+
f"'{effective_service}'. No log files exist."
|
|
210
|
+
)
|
|
211
|
+
sys.exit(1)
|
|
212
|
+
|
|
213
|
+
# Handle --follow mode
|
|
214
|
+
if follow:
|
|
215
|
+
num = tail_lines if tail_lines is not None else DEFAULT_TAIL_LINES
|
|
216
|
+
try:
|
|
217
|
+
follow_log(log_path, num_lines=num, output_callback=click.echo)
|
|
218
|
+
except KeyboardInterrupt:
|
|
219
|
+
# Belt-and-suspenders: ensure clean exit
|
|
220
|
+
pass
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
# Default: show tail of log
|
|
224
|
+
num = tail_lines if tail_lines is not None else DEFAULT_TAIL_LINES
|
|
225
|
+
content = read_log_tail(log_path, num_lines=num)
|
|
226
|
+
if content:
|
|
227
|
+
click.echo(content)
|
|
228
|
+
else:
|
|
229
|
+
console.print(
|
|
230
|
+
Text(
|
|
231
|
+
f"Log file for service '{effective_service}' is empty.",
|
|
232
|
+
style="yellow",
|
|
233
|
+
)
|
|
234
|
+
)
|