local-llm-ctl 0.1.0__tar.gz

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.
@@ -0,0 +1,8 @@
1
+ include README.md
2
+ include pyproject.toml
3
+ include LICENSE
4
+ recursive-include src/llm_ctl *.py
5
+ recursive-include tests *.py
6
+ prune src/llm_ctl.egg-info
7
+ prune build
8
+ prune dist
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: local-llm-ctl
3
+ Version: 0.1.0
4
+ Summary: CLI tool to manage local LLM models and role-based server lifecycle
5
+ Author: Renato Rozas
6
+ License-Expression: MIT
7
+ Requires-Python: >=3.9
8
+ Requires-Dist: click>=8.0
9
+ Requires-Dist: huggingface-hub>=0.20
@@ -0,0 +1,221 @@
1
+ # llm-ctl
2
+
3
+ A CLI tool to manage local LLM models and role-based server lifecycle.
4
+
5
+ Register models to roles (planner, reviewer, engineer, assistant), start/stop inference servers, and monitor their status — all from the terminal.
6
+
7
+ ## Features
8
+
9
+ - **Model discovery** — Automatically scans HuggingFace cache and local GGUF files
10
+ - **Role-based management** — Assign models to planner, reviewer, engineer, or assistant roles
11
+ - **Server lifecycle** — Start, stop, and monitor inference servers with PID tracking
12
+ - **Auto port assignment** — Automatically finds available ports for each role
13
+ - **MLX & GGUF support** — Works with both Apple MLX and llama.cpp servers
14
+ - **State persistence** — Survives restarts via JSON state file
15
+
16
+ ## Dependencies
17
+
18
+ ### Required
19
+
20
+ - **Python 3.9+**
21
+ - **click** — CLI framework
22
+ - **huggingface-hub** — HuggingFace model management
23
+
24
+ ### Server backend (one required)
25
+
26
+ You need at least one inference backend to serve models:
27
+
28
+ - **mlx-lm** — For serving MLX models on Apple Silicon (`mlx_lm serve`)
29
+ - **llama.cpp** — For serving GGUF models on any platform (`llama-server`)
30
+
31
+ Install one or both depending on your needs:
32
+
33
+ ```bash
34
+ # MLX models (Apple Silicon Macs)
35
+ pip install mlx-lm
36
+
37
+ # GGUF models (any platform)
38
+ brew install llama.cpp
39
+ ```
40
+
41
+ ## Installation
42
+
43
+ ### Quick install (recommended)
44
+
45
+ ```bash
46
+ pip install llm-ctl
47
+ ```
48
+
49
+ ### From source
50
+
51
+ ```bash
52
+ git clone https://github.com/renatorozas/llm-ctl.git
53
+ cd llm-ctl
54
+ python -m venv .venv
55
+ source .venv/bin/activate
56
+ pip install -e .
57
+ ```
58
+
59
+ ### With uv
60
+
61
+ ```bash
62
+ uv pip install llm-ctl
63
+ ```
64
+
65
+ ### With pipx (system-wide, isolated)
66
+
67
+ ```bash
68
+ pipx install llm-ctl
69
+ ```
70
+
71
+ ## Setup
72
+
73
+ ### 1. Install a server backend
74
+
75
+ For **MLX models** (Apple Silicon Macs):
76
+
77
+ ```bash
78
+ pip install mlx-lm
79
+ ```
80
+
81
+ For **GGUF models** (any platform):
82
+
83
+ ```bash
84
+ # Install llama.cpp (follow instructions at https://github.com/ggerganov/llama.cpp)
85
+ # On macOS with Homebrew:
86
+ brew install llama.cpp
87
+ ```
88
+
89
+ ### 2. Download models
90
+
91
+ ```bash
92
+ # Download from HuggingFace
93
+ llm-ctl download mlx-community/Qwen3.6-27B-4bit
94
+
95
+ # Or use models already in your HuggingFace cache
96
+ llm-ctl list
97
+ ```
98
+
99
+ ### 3. Register a role
100
+
101
+ ```bash
102
+ # Register a model to a role (auto-detects type and assigns a port)
103
+ llm-ctl register planner mlx-community/Qwen3.6-27B-4bit
104
+
105
+ # Override model type or port
106
+ llm-ctl register reviewer /path/to/model.gguf --type gguf --port 8081
107
+ ```
108
+
109
+ ### 4. Start servers
110
+
111
+ ```bash
112
+ # Start a single role
113
+ llm-ctl start planner
114
+
115
+ # Start all registered roles
116
+ llm-ctl start --all
117
+
118
+ # With custom timeout
119
+ llm-ctl start planner --timeout 120
120
+ ```
121
+
122
+ ### 5. Check status
123
+
124
+ ```bash
125
+ llm-ctl status
126
+ ```
127
+
128
+ ### 6. Stop servers
129
+
130
+ ```bash
131
+ # Stop a single role
132
+ llm-ctl stop planner
133
+
134
+ # Stop all roles
135
+ llm-ctl stop --all
136
+ ```
137
+
138
+ ## Commands
139
+
140
+ | Command | Description |
141
+ |---------|-------------|
142
+ | `llm-ctl list` | List all available local models |
143
+ | `llm-ctl register <role> <model_id>` | Register a model to a role |
144
+ | `llm-ctl unregister <role>` | Unregister a model from a role |
145
+ | `llm-ctl start [role]` | Start server for a role (or `--all`) |
146
+ | `llm-ctl stop [role]` | Stop server for a role (or `--all`) |
147
+ | `llm-ctl status` | Show status of all registered roles |
148
+ | `llm-ctl config [show\|edit]` | View or edit the configuration file |
149
+ | `llm-ctl download <model_id>` | Download a model from HuggingFace |
150
+
151
+ ## Configuration
152
+
153
+ Configuration is stored in `~/.config/llm-ctl/config.json`:
154
+
155
+ ```json
156
+ {
157
+ "roles": {
158
+ "planner": {
159
+ "model_id": "mlx-community/Qwen3.6-27B-4bit",
160
+ "path": "/Users/renato/models/huggingface/hub/models--mlx-community--Qwen3.6-27B-4bit/snapshots/abc123",
161
+ "type": "mlx",
162
+ "port": 8080
163
+ }
164
+ },
165
+ "defaults": {
166
+ "base_port": 8080
167
+ }
168
+ }
169
+ ```
170
+
171
+ View or edit the config:
172
+
173
+ ```bash
174
+ llm-ctl config show
175
+ llm-ctl config edit
176
+ ```
177
+
178
+ ## State
179
+
180
+ Runtime state (PIDs, ports, start times) is stored in `~/.config/llm-ctl/state.json`. Stale entries are automatically cleaned up on status checks.
181
+
182
+ Server logs are written to `~/.config/llm-ctl/logs/<role>.log`.
183
+
184
+ ## Roles
185
+
186
+ | Role | Description |
187
+ |------|-------------|
188
+ | `planner` | Planning and task decomposition |
189
+ | `reviewer` | Code review and quality checks |
190
+ | `engineer` | Implementation and coding |
191
+ | `assistant` | General assistance |
192
+
193
+ ## Testing
194
+
195
+ ```bash
196
+ python -m pytest tests/ -v
197
+ ```
198
+
199
+ ## Project Structure
200
+
201
+ ```
202
+ llm-ctl/
203
+ ├── src/llm_ctl/
204
+ │ ├── __init__.py
205
+ │ ├── __main__.py
206
+ │ ├── cli.py # Click CLI commands
207
+ │ ├── config.py # Configuration management
208
+ │ ├── models.py # Model discovery and management
209
+ │ ├── server.py # Server lifecycle management
210
+ │ └── utils.py # Utility functions
211
+ ├── tests/
212
+ │ ├── test_config.py
213
+ │ ├── test_models.py
214
+ │ ├── test_server.py
215
+ │ └── test_utils.py
216
+ └── pyproject.toml
217
+ ```
218
+
219
+ ## License
220
+
221
+ MIT
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "local-llm-ctl"
7
+ version = "0.1.0"
8
+ description = "CLI tool to manage local LLM models and role-based server lifecycle"
9
+ requires-python = ">=3.9"
10
+ license = "MIT"
11
+ authors = [
12
+ {name = "Renato Rozas"}
13
+ ]
14
+ dependencies = [
15
+ "click>=8.0",
16
+ "huggingface-hub>=0.20",
17
+ ]
18
+
19
+ [project.scripts]
20
+ llm-ctl = "llm_ctl.cli:cli"
21
+
22
+ [tool.setuptools.packages.find]
23
+ where = ["src"]
24
+ exclude = ["*.egg-info"]
25
+
26
+ [tool.setuptools.package-data]
27
+ llm_ctl = ["*.py"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """llm-ctl: CLI tool to manage local LLM models."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from llm_ctl.cli import cli
2
+
3
+ if __name__ == "__main__":
4
+ cli()
@@ -0,0 +1,294 @@
1
+ """CLI entry point for llm-ctl."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+
11
+ from .config import ConfigManager, VALID_ROLES, CONFIG_PATH
12
+ from .models import discover_models, find_model_by_id, register_model, unregister_model
13
+ from .server import ServerManager, StateFile
14
+ from .utils import is_port_available, find_next_available_port
15
+
16
+
17
+ @click.group()
18
+ @click.version_option(version="0.1.0", prog_name="llm-ctl")
19
+ def cli():
20
+ """Manage local LLM models and role-based server lifecycle."""
21
+ pass
22
+
23
+
24
+ @cli.command("list")
25
+ def list_models():
26
+ """List all available local models."""
27
+ models = discover_models()
28
+ if not models:
29
+ click.echo("No models found.")
30
+ return
31
+
32
+ click.echo(f"\n{'Model ID':<55} {'Type':<6} {'Path'}")
33
+ click.echo("-" * 100)
34
+ for model in sorted(models, key=lambda m: m["model_id"]):
35
+ path_display = model["path"]
36
+ if len(path_display) > 40:
37
+ path_display = "..." + path_display[-37:]
38
+ click.echo(f"{model['model_id']:<55} {model['type']:<6} {path_display}")
39
+
40
+ click.echo(f"\nTotal: {len(models)} model(s)")
41
+
42
+
43
+ @cli.command()
44
+ @click.argument("role")
45
+ @click.argument("model_id")
46
+ @click.option("--port", "-p", type=int, default=None, help="Override auto-assigned port")
47
+ @click.option("--type", "-t", "model_type", type=click.Choice(["mlx", "gguf"]), default=None,
48
+ help="Override auto-detected model type")
49
+ def register(role, model_id, port, model_type):
50
+ """Register a model to a role.
51
+
52
+ ROLE: planner, reviewer, engineer, or assistant
53
+
54
+ MODEL_ID: HuggingFace model ID (e.g., lmstudio-community/Qwen3.6-27B-MLX-6bit)
55
+ """
56
+ if role not in VALID_ROLES:
57
+ click.echo(f"Error: Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}")
58
+ sys.exit(1)
59
+
60
+ try:
61
+ config = ConfigManager()
62
+ discovered = discover_models()
63
+ model = find_model_by_id(model_id, discovered)
64
+ if not model:
65
+ click.echo(f"Error: Model not found: {model_id}")
66
+ click.echo("Run 'llm-ctl list' to see available models.")
67
+ sys.exit(1)
68
+
69
+ # Check if role is already registered
70
+ existing = config.get_role(role)
71
+ if existing:
72
+ click.echo(f"Role '{role}' is already registered to: {existing['model_id']}")
73
+ if not click.confirm("Unregister and re-register?"):
74
+ return
75
+
76
+ # Check for shared-model warning
77
+ all_roles = config.get_all_roles()
78
+ for other_role, other_config in all_roles.items():
79
+ if other_role == role:
80
+ continue
81
+ if other_config.get("model_id") == model_id:
82
+ click.echo(
83
+ f"Warning: Role '{other_role}' is already using '{model_id}'. "
84
+ f"Each role runs a separate server process, which means duplicate "
85
+ f"GPU memory usage. Consider using --all with a single role or "
86
+ f"assigning different models."
87
+ )
88
+ break
89
+
90
+ # Use user-specified type or auto-detected type
91
+ effective_type = model_type if model_type else model["type"]
92
+
93
+ # Auto-assign or use provided port
94
+ used_ports = {r.get("port") for r in all_roles.values() if r.get("port") and r.get("model_id") != model_id}
95
+ if port:
96
+ if not is_port_available(port):
97
+ click.echo(f"Error: Port {port} is not available.")
98
+ sys.exit(1)
99
+ else:
100
+ base_port = config.get_base_port()
101
+ port = find_next_available_port(start=base_port, exclude=used_ports)
102
+
103
+ role_config = {
104
+ "model_id": model["model_id"],
105
+ "path": model["path"],
106
+ "type": effective_type,
107
+ "port": port,
108
+ }
109
+ config.register_role(role, role_config)
110
+ click.echo(f"Registered '{model_id}' to role '{role}' on port {port} (type: {effective_type})")
111
+
112
+ except ValueError as e:
113
+ click.echo(f"Error: {e}")
114
+ sys.exit(1)
115
+
116
+
117
+ @cli.command()
118
+ @click.argument("role")
119
+ def unregister(role):
120
+ """Unregister a model from a role."""
121
+ if role not in VALID_ROLES:
122
+ click.echo(f"Error: Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}")
123
+ sys.exit(1)
124
+
125
+ config = ConfigManager()
126
+ if not config.get_role(role):
127
+ click.echo(f"Role '{role}' is not registered.")
128
+ return
129
+
130
+ # Stop server if running
131
+ server = ServerManager()
132
+ server.stop(role)
133
+
134
+ config.unregister_role(role)
135
+ click.echo(f"Unregistered role '{role}'")
136
+
137
+
138
+ @cli.command()
139
+ @click.argument("role", required=False)
140
+ @click.option("--all", "-a", "all_roles", is_flag=True, help="Start all registered role servers")
141
+ @click.option("--timeout", "-t", type=int, default=None, help="Override startup timeout in seconds")
142
+ def start(role, all_roles, timeout):
143
+ """Start the server for a role, or all roles with --all."""
144
+ # Guard against --all + positional role conflict
145
+ if all_roles and role:
146
+ click.echo("Error: Cannot specify both --all and a role.")
147
+ sys.exit(1)
148
+
149
+ server = ServerManager()
150
+ if timeout:
151
+ server.STARTUP_TIMEOUT = timeout
152
+ config = ConfigManager()
153
+
154
+ if all_roles:
155
+ results = server.start_all()
156
+ for r in results:
157
+ status_icon = "\u2713" if r["status"] == "started" else "\u2717"
158
+ detail = f"port {r.get('info', {}).get('port', '?')}" if r["status"] == "started" else r.get("error", "")
159
+ click.echo(f" {status_icon} {r['role']}: {r['status']} {detail}")
160
+ return
161
+
162
+ if not role:
163
+ click.echo("Error: Specify a role or use --all.")
164
+ click.echo(f"Available roles: {', '.join(sorted(VALID_ROLES))}")
165
+ sys.exit(1)
166
+
167
+ if role not in VALID_ROLES:
168
+ click.echo(f"Error: Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}")
169
+ sys.exit(1)
170
+
171
+ role_config = config.get_role(role)
172
+ if not role_config:
173
+ click.echo(f"Error: Role '{role}' is not registered. Run 'llm-ctl register {role} <model_id>'")
174
+ sys.exit(1)
175
+
176
+ try:
177
+ state = server.start(role, role_config)
178
+ click.echo(f"Started {role} server (PID {state['pid']}, port {state['port']})")
179
+ click.echo(f"Log: {state.get('log_file', 'N/A')}")
180
+ except RuntimeError as e:
181
+ click.echo(f"Error: {e}")
182
+ sys.exit(1)
183
+
184
+
185
+ @cli.command()
186
+ @click.argument("role", required=False)
187
+ @click.option("--all", "-a", "all_roles", is_flag=True, help="Stop all running servers")
188
+ def stop(role, all_roles):
189
+ """Stop the server for a role, or all roles with --all."""
190
+ # Guard against --all + positional role conflict
191
+ if all_roles and role:
192
+ click.echo("Error: Cannot specify both --all and a role.")
193
+ sys.exit(1)
194
+
195
+ server = ServerManager()
196
+
197
+ if all_roles:
198
+ results = server.stop_all()
199
+ for r in results:
200
+ click.echo(f" {r['role']}: {r['status']}")
201
+ return
202
+
203
+ if not role:
204
+ click.echo("Error: Specify a role or use --all.")
205
+ sys.exit(1)
206
+
207
+ stopped = server.stop(role)
208
+ if stopped:
209
+ click.echo(f"Stopped {role} server")
210
+ else:
211
+ click.echo(f"Role '{role}' server is not running")
212
+
213
+
214
+ @cli.command()
215
+ def status():
216
+ """Show status of all registered roles and their servers."""
217
+ config = ConfigManager()
218
+ server = ServerManager()
219
+
220
+ roles = config.get_all_roles()
221
+ if not roles:
222
+ click.echo("No roles registered. Run 'llm-ctl register <role> <model_id>'")
223
+ return
224
+
225
+ statuses = server.get_all_status()
226
+
227
+ click.echo(f"\n{'Role':<12} {'Model':<50} {'Type':<6} {'Port':<6} {'Status':<10} {'PID'}")
228
+ click.echo("-" * 100)
229
+ for s in statuses:
230
+ running = "running" if s["running"] else "stopped"
231
+ pid = str(s["pid"]) if s["pid"] else "-"
232
+ model_id = s.get("model_id", "-")
233
+ if len(model_id) > 48:
234
+ model_id = "..." + model_id[-45:]
235
+ click.echo(f"{s['role']:<12} {model_id:<50} {s.get('type', '-'):6} {str(s.get('port', '-')):<6} {running:<10} {pid}")
236
+
237
+
238
+ @cli.command()
239
+ @click.argument("model_id")
240
+ @click.option("--cache-dir", default=None, help="Custom HuggingFace cache directory")
241
+ def download(model_id, cache_dir):
242
+ """Download a model from HuggingFace.
243
+
244
+ MODEL_ID: HuggingFace model ID (e.g., mlx-community/Qwen3.6-27B-4bit)
245
+ """
246
+ try:
247
+ from huggingface_hub import snapshot_download
248
+ except ImportError:
249
+ click.echo("Error: huggingface-hub is not installed. Run: pip install huggingface-hub")
250
+ sys.exit(1)
251
+
252
+ click.echo(f"Downloading {model_id}...")
253
+ try:
254
+ if cache_dir:
255
+ cachedir = cache_dir
256
+ else:
257
+ cachedir = str(Path.home() / "models" / "huggingface" / "hub")
258
+
259
+ path = snapshot_download(
260
+ repo_id=model_id,
261
+ cache_dir=cachedir,
262
+ )
263
+ click.echo(f"Downloaded to: {path}")
264
+ except Exception as e:
265
+ click.echo(f"Error downloading model: {e}")
266
+ sys.exit(1)
267
+
268
+
269
+ @cli.command()
270
+ @click.argument("action", required=False, default="show")
271
+ def config(action):
272
+ """View or edit the configuration file.
273
+
274
+ ACTION: show (default) or edit (opens in $EDITOR)
275
+ """
276
+ cfg = ConfigManager()
277
+
278
+ if action == "show":
279
+ data = cfg.load()
280
+ click.echo(json.dumps(data, indent=2))
281
+ elif action == "edit":
282
+ editor = os.environ.get("EDITOR", "vi")
283
+ # Ensure file exists
284
+ if not cfg.config_path.exists():
285
+ cfg.save(cfg.load())
286
+ subprocess.call([editor, str(cfg.config_path)])
287
+ click.echo(f"Config file: {cfg.config_path}")
288
+ else:
289
+ click.echo(f"Unknown action: {action}. Use 'show' or 'edit'.")
290
+ sys.exit(1)
291
+
292
+
293
+ if __name__ == "__main__":
294
+ cli()
@@ -0,0 +1,82 @@
1
+ """Configuration management for llm-ctl."""
2
+
3
+ import json
4
+ import os
5
+ from pathlib import Path
6
+ from typing import Any, Dict, Optional
7
+
8
+ DEFAULT_CONFIG: Dict[str, Any] = {
9
+ "roles": {},
10
+ "defaults": {
11
+ "base_port": 8080,
12
+ "models_dirs": [
13
+ os.path.expanduser("~/models/huggingface/hub"),
14
+ os.path.expanduser("~/models"),
15
+ ]
16
+ }
17
+ }
18
+
19
+ VALID_ROLES = {"planner", "reviewer", "engineer", "assistant"}
20
+
21
+ CONFIG_DIR = Path.home() / ".config" / "llm-ctl"
22
+ CONFIG_PATH = CONFIG_DIR / "config.json"
23
+
24
+
25
+ class ConfigManager:
26
+ def __init__(self, config_path: Optional[str] = None):
27
+ self.config_path = Path(config_path) if config_path else CONFIG_PATH
28
+
29
+ def load(self) -> Dict[str, Any]:
30
+ if not self.config_path.exists():
31
+ return self._default_config()
32
+ with open(self.config_path, "r") as f:
33
+ data = json.load(f)
34
+ # Merge with defaults to ensure new keys appear
35
+ merged = json.loads(json.dumps(DEFAULT_CONFIG))
36
+ merged["roles"].update(data.get("roles", {}))
37
+ if "defaults" in data:
38
+ merged["defaults"].update(data["defaults"])
39
+ return merged
40
+
41
+ def save(self, config: Dict[str, Any]) -> None:
42
+ self.config_path.parent.mkdir(parents=True, exist_ok=True)
43
+ with open(self.config_path, "w") as f:
44
+ json.dump(config, f, indent=2)
45
+ f.write("\n")
46
+
47
+ def register_role(self, role: str, role_config: Dict[str, Any]) -> None:
48
+ if role not in VALID_ROLES:
49
+ raise ValueError(f"Invalid role '{role}'. Must be one of: {', '.join(sorted(VALID_ROLES))}")
50
+ config = self.load()
51
+ config["roles"][role] = role_config
52
+ self.save(config)
53
+
54
+ def unregister_role(self, role: str) -> None:
55
+ config = self.load()
56
+ if role in config["roles"]:
57
+ del config["roles"][role]
58
+ self.save(config)
59
+
60
+ def get_role(self, role: str) -> Optional[Dict[str, Any]]:
61
+ config = self.load()
62
+ return config["roles"].get(role)
63
+
64
+ def get_all_roles(self) -> Dict[str, Any]:
65
+ config = self.load()
66
+ return config["roles"]
67
+
68
+ def get_base_port(self) -> int:
69
+ config = self.load()
70
+ return config["defaults"].get("base_port", 8080)
71
+
72
+ def get_models_dirs(self) -> list:
73
+ config = self.load()
74
+ return config["defaults"].get("models_dirs", DEFAULT_CONFIG["defaults"]["models_dirs"])
75
+
76
+ @staticmethod
77
+ def _default_config() -> Dict[str, Any]:
78
+ return json.loads(json.dumps(DEFAULT_CONFIG))
79
+
80
+ @property
81
+ def path(self) -> Path:
82
+ return self.config_path