ui-cli 1.2.1__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 (46) hide show
  1. ui_cli/__init__.py +31 -0
  2. ui_cli/client.py +269 -0
  3. ui_cli/commands/__init__.py +1 -0
  4. ui_cli/commands/devices.py +187 -0
  5. ui_cli/commands/groups.py +503 -0
  6. ui_cli/commands/hosts.py +114 -0
  7. ui_cli/commands/isp.py +100 -0
  8. ui_cli/commands/local/__init__.py +63 -0
  9. ui_cli/commands/local/apgroups.py +445 -0
  10. ui_cli/commands/local/clients.py +1537 -0
  11. ui_cli/commands/local/config.py +758 -0
  12. ui_cli/commands/local/devices.py +570 -0
  13. ui_cli/commands/local/dpi.py +369 -0
  14. ui_cli/commands/local/events.py +289 -0
  15. ui_cli/commands/local/firewall.py +285 -0
  16. ui_cli/commands/local/health.py +195 -0
  17. ui_cli/commands/local/networks.py +426 -0
  18. ui_cli/commands/local/portfwd.py +153 -0
  19. ui_cli/commands/local/stats.py +234 -0
  20. ui_cli/commands/local/utils.py +85 -0
  21. ui_cli/commands/local/vouchers.py +410 -0
  22. ui_cli/commands/local/wan.py +302 -0
  23. ui_cli/commands/local/wlans.py +257 -0
  24. ui_cli/commands/mcp.py +416 -0
  25. ui_cli/commands/sdwan.py +168 -0
  26. ui_cli/commands/sites.py +65 -0
  27. ui_cli/commands/speedtest.py +192 -0
  28. ui_cli/commands/status.py +410 -0
  29. ui_cli/commands/version.py +13 -0
  30. ui_cli/config.py +106 -0
  31. ui_cli/groups.py +567 -0
  32. ui_cli/local_client.py +897 -0
  33. ui_cli/main.py +61 -0
  34. ui_cli/models.py +188 -0
  35. ui_cli/output.py +251 -0
  36. ui_cli-1.2.1.dist-info/METADATA +1315 -0
  37. ui_cli-1.2.1.dist-info/RECORD +46 -0
  38. ui_cli-1.2.1.dist-info/WHEEL +4 -0
  39. ui_cli-1.2.1.dist-info/entry_points.txt +3 -0
  40. ui_cli-1.2.1.dist-info/licenses/LICENSE +21 -0
  41. ui_mcp/ARCHITECTURE.md +243 -0
  42. ui_mcp/README.md +235 -0
  43. ui_mcp/__init__.py +7 -0
  44. ui_mcp/__main__.py +10 -0
  45. ui_mcp/cli_runner.py +112 -0
  46. ui_mcp/server.py +468 -0
@@ -0,0 +1,410 @@
1
+ """Status command - check API connectivity and authentication."""
2
+
3
+ import asyncio
4
+ import time
5
+ from typing import Annotated
6
+
7
+ import httpx
8
+ import typer
9
+ from rich.progress import Progress, SpinnerColumn, TextColumn
10
+
11
+ from ui_cli import __version__
12
+ from ui_cli.config import settings
13
+ from ui_cli.local_client import (
14
+ LocalAPIError,
15
+ LocalAuthenticationError,
16
+ LocalConnectionError,
17
+ UniFiLocalClient,
18
+ )
19
+ from ui_cli.output import OutputFormat, console, output_json
20
+
21
+ # Timeout for status checks (seconds)
22
+ STATUS_CHECK_TIMEOUT = 10
23
+
24
+
25
+ app = typer.Typer(help="Check API connectivity and authentication status")
26
+
27
+
28
+ def mask_api_key(key: str, show_full: bool = False) -> str:
29
+ """Mask API key for display."""
30
+ if not key:
31
+ return "(not configured)"
32
+ if show_full:
33
+ return key
34
+ if len(key) <= 8:
35
+ return "****"
36
+ return f"****...{key[-6:]}"
37
+
38
+
39
+ async def check_site_manager_api(verbose: bool = False) -> dict:
40
+ """Check Site Manager API connectivity and auth."""
41
+ result = {
42
+ "name": "Site Manager API",
43
+ "url": settings.api_url,
44
+ "api_key_configured": bool(settings.api_key),
45
+ "api_key_display": mask_api_key(settings.api_key, show_full=verbose),
46
+ "connection": None,
47
+ "connection_time_ms": None,
48
+ "authentication": None,
49
+ "error": None,
50
+ "hosts_count": None,
51
+ "sites_count": None,
52
+ "devices_count": None,
53
+ }
54
+
55
+ if not settings.api_key:
56
+ result["error"] = "Set UNIFI_API_KEY in .env file"
57
+ return result
58
+
59
+ headers = {
60
+ "X-API-Key": settings.api_key,
61
+ "Accept": "application/json",
62
+ }
63
+
64
+ try:
65
+ async with httpx.AsyncClient(timeout=STATUS_CHECK_TIMEOUT) as client:
66
+ # Test connection and auth with hosts endpoint
67
+ start = time.perf_counter()
68
+ response = await client.get(
69
+ f"{settings.api_url}/hosts",
70
+ headers=headers,
71
+ )
72
+ elapsed_ms = (time.perf_counter() - start) * 1000
73
+
74
+ result["connection"] = "OK"
75
+ result["connection_time_ms"] = round(elapsed_ms, 1)
76
+
77
+ if response.status_code == 200:
78
+ result["authentication"] = "Valid"
79
+ data = response.json()
80
+ hosts = data.get("data", [])
81
+ result["hosts_count"] = len(hosts)
82
+
83
+ # Get sites count
84
+ sites_resp = await client.get(
85
+ f"{settings.api_url}/sites",
86
+ headers=headers,
87
+ )
88
+ if sites_resp.status_code == 200:
89
+ sites_data = sites_resp.json()
90
+ result["sites_count"] = len(sites_data.get("data", []))
91
+
92
+ # Get devices count
93
+ devices_resp = await client.get(
94
+ f"{settings.api_url}/devices",
95
+ headers=headers,
96
+ )
97
+ if devices_resp.status_code == 200:
98
+ devices_data = devices_resp.json()
99
+ # Flatten devices from host groups
100
+ total_devices = 0
101
+ for host_group in devices_data.get("data", []):
102
+ total_devices += len(host_group.get("devices", []))
103
+ result["devices_count"] = total_devices
104
+
105
+ elif response.status_code == 401:
106
+ result["authentication"] = "FAILED"
107
+ result["error"] = "Invalid API key"
108
+ elif response.status_code == 429:
109
+ result["authentication"] = "Valid"
110
+ result["error"] = "Rate limit exceeded"
111
+ else:
112
+ result["authentication"] = "FAILED"
113
+ result["error"] = f"HTTP {response.status_code}"
114
+
115
+ except httpx.ConnectError:
116
+ result["connection"] = "FAILED"
117
+ result["error"] = "Could not connect to api.ui.com"
118
+ except httpx.TimeoutException:
119
+ result["connection"] = "FAILED"
120
+ result["error"] = "Connection timeout"
121
+ except Exception as e:
122
+ result["connection"] = "FAILED"
123
+ result["error"] = str(e)
124
+
125
+ return result
126
+
127
+
128
+ async def check_local_controller(verbose: bool = False) -> dict:
129
+ """Check Local Controller connectivity and auth."""
130
+ # Determine auth method for display
131
+ if settings.controller_api_key:
132
+ auth_method = "API Key"
133
+ elif settings.controller_username:
134
+ auth_method = "Username/Password"
135
+ else:
136
+ auth_method = "(not configured)"
137
+
138
+ result = {
139
+ "name": "Local Controller",
140
+ "url": settings.controller_url or "(not configured)",
141
+ "username": settings.controller_username or "(not configured)",
142
+ "site": settings.controller_site or "default",
143
+ "configured": settings.is_local_configured,
144
+ "auth_method": auth_method,
145
+ "connection": None,
146
+ "connection_time_ms": None,
147
+ "authentication": None,
148
+ "error": None,
149
+ "controller_type": None,
150
+ "clients_count": None,
151
+ "devices_count": None,
152
+ }
153
+
154
+ if not settings.controller_url:
155
+ result["error"] = "Set UNIFI_CONTROLLER_URL in .env file"
156
+ return result
157
+
158
+ if not settings.is_local_configured:
159
+ result["error"] = (
160
+ "Set UNIFI_CONTROLLER_API_KEY or "
161
+ "UNIFI_CONTROLLER_USERNAME/UNIFI_CONTROLLER_PASSWORD in .env file"
162
+ )
163
+ return result
164
+
165
+ try:
166
+ client = UniFiLocalClient(timeout=STATUS_CHECK_TIMEOUT)
167
+ start = time.perf_counter()
168
+ if settings.controller_api_key:
169
+ # API key mode: no session handshake needed — use a lightweight request
170
+ await client.get_health()
171
+ else:
172
+ await client.login()
173
+ elapsed_ms = (time.perf_counter() - start) * 1000
174
+
175
+ result["connection"] = "OK"
176
+ result["connection_time_ms"] = round(elapsed_ms, 1)
177
+ result["authentication"] = "Valid"
178
+ result["controller_type"] = "UDM" if client._is_udm else "Cloud Key/Self-hosted"
179
+
180
+ # Get counts
181
+ try:
182
+ clients = await client.list_clients()
183
+ result["clients_count"] = len(clients)
184
+ except LocalAPIError:
185
+ pass
186
+
187
+ try:
188
+ devices = await client.get_devices()
189
+ result["devices_count"] = len(devices)
190
+ except LocalAPIError:
191
+ pass
192
+
193
+ except LocalAuthenticationError as e:
194
+ result["connection"] = "OK"
195
+ result["authentication"] = "FAILED"
196
+ result["error"] = str(e)
197
+ except LocalConnectionError as e:
198
+ result["connection"] = "FAILED"
199
+ result["error"] = str(e)
200
+ except Exception as e:
201
+ result["connection"] = "FAILED"
202
+ result["error"] = str(e)
203
+
204
+ return result
205
+
206
+
207
+ def print_status_table(cloud_status: dict, local_status: dict | None = None) -> None:
208
+ """Print status in formatted table."""
209
+ from rich.table import Table
210
+
211
+ console.print()
212
+ console.print(f"[bold cyan]UniFi CLI v{__version__}[/bold cyan]")
213
+ console.print("─" * 40)
214
+ console.print()
215
+
216
+ # Site Manager API section
217
+ console.print("[bold]Site Manager API[/bold] (api.ui.com)")
218
+
219
+ table = Table(show_header=False, box=None, padding=(0, 2))
220
+ table.add_column("Key", style="dim")
221
+ table.add_column("Value")
222
+
223
+ table.add_row("URL:", cloud_status["url"])
224
+
225
+ # API Key status
226
+ if cloud_status["api_key_configured"]:
227
+ table.add_row("API Key:", f"[green]{cloud_status['api_key_display']}[/green] (configured)")
228
+ else:
229
+ table.add_row("API Key:", f"[red]{cloud_status['api_key_display']}[/red]")
230
+
231
+ # Connection status
232
+ if cloud_status["connection"] == "OK":
233
+ table.add_row(
234
+ "Connection:",
235
+ f"[green]OK[/green] ({cloud_status['connection_time_ms']}ms)"
236
+ )
237
+ elif cloud_status["connection"] == "FAILED":
238
+ table.add_row("Connection:", "[red]FAILED[/red]")
239
+ else:
240
+ table.add_row("Connection:", "[dim]-[/dim]")
241
+
242
+ # Authentication status
243
+ if cloud_status["authentication"] == "Valid":
244
+ table.add_row("Authentication:", "[green]Valid[/green]")
245
+ elif cloud_status["authentication"] == "FAILED":
246
+ table.add_row("Authentication:", "[red]FAILED[/red]")
247
+ else:
248
+ table.add_row("Authentication:", "[dim]-[/dim]")
249
+
250
+ console.print(table)
251
+
252
+ # Error message
253
+ if cloud_status["error"]:
254
+ console.print()
255
+ console.print(f" [red]Error:[/red] {cloud_status['error']}")
256
+
257
+ # Account info (if authenticated)
258
+ if cloud_status["authentication"] == "Valid" and cloud_status["hosts_count"] is not None:
259
+ console.print()
260
+ console.print("[bold]Account Summary:[/bold]")
261
+
262
+ info_table = Table(show_header=False, box=None, padding=(0, 2))
263
+ info_table.add_column("Key", style="dim")
264
+ info_table.add_column("Value")
265
+
266
+ info_table.add_row("Hosts:", str(cloud_status["hosts_count"]))
267
+ info_table.add_row("Sites:", str(cloud_status["sites_count"]))
268
+ info_table.add_row("Devices:", str(cloud_status["devices_count"]))
269
+
270
+ console.print(info_table)
271
+
272
+ # Local Controller section
273
+ if local_status:
274
+ console.print()
275
+ console.print("[bold]Local Controller[/bold]")
276
+
277
+ local_table = Table(show_header=False, box=None, padding=(0, 2))
278
+ local_table.add_column("Key", style="dim")
279
+ local_table.add_column("Value")
280
+
281
+ local_table.add_row("URL:", local_status["url"])
282
+ local_table.add_row("Site:", local_status["site"])
283
+
284
+ if local_status["configured"]:
285
+ local_table.add_row("Username:", f"[green]{local_status['username']}[/green]")
286
+ else:
287
+ local_table.add_row("Username:", f"[red]{local_status['username']}[/red]")
288
+
289
+ # Connection status
290
+ if local_status["connection"] == "OK":
291
+ local_table.add_row(
292
+ "Connection:",
293
+ f"[green]OK[/green] ({local_status['connection_time_ms']}ms)"
294
+ )
295
+ elif local_status["connection"] == "FAILED":
296
+ local_table.add_row("Connection:", "[red]FAILED[/red]")
297
+ else:
298
+ local_table.add_row("Connection:", "[dim]-[/dim]")
299
+
300
+ # Authentication status
301
+ if local_status["authentication"] == "Valid":
302
+ local_table.add_row("Authentication:", "[green]Valid[/green]")
303
+ elif local_status["authentication"] == "FAILED":
304
+ local_table.add_row("Authentication:", "[red]FAILED[/red]")
305
+ else:
306
+ local_table.add_row("Authentication:", "[dim]-[/dim]")
307
+
308
+ # Controller type
309
+ if local_status["controller_type"]:
310
+ local_table.add_row("Type:", local_status["controller_type"])
311
+
312
+ console.print(local_table)
313
+
314
+ # Error message
315
+ if local_status["error"]:
316
+ console.print()
317
+ console.print(f" [red]Error:[/red] {local_status['error']}")
318
+
319
+ # Controller info (if authenticated)
320
+ if local_status["authentication"] == "Valid":
321
+ console.print()
322
+ console.print("[bold]Controller Summary:[/bold]")
323
+
324
+ ctrl_table = Table(show_header=False, box=None, padding=(0, 2))
325
+ ctrl_table.add_column("Key", style="dim")
326
+ ctrl_table.add_column("Value")
327
+
328
+ if local_status["clients_count"] is not None:
329
+ ctrl_table.add_row("Clients:", str(local_status["clients_count"]))
330
+ if local_status["devices_count"] is not None:
331
+ ctrl_table.add_row("Devices:", str(local_status["devices_count"]))
332
+
333
+ console.print(ctrl_table)
334
+
335
+ console.print()
336
+
337
+
338
+ async def check_all_status(verbose: bool = False) -> tuple[dict, dict]:
339
+ """Check both Cloud API and Local Controller status."""
340
+ cloud_status = await check_site_manager_api(verbose=verbose)
341
+ local_status = await check_local_controller(verbose=verbose)
342
+ return cloud_status, local_status
343
+
344
+
345
+ async def check_with_spinner(verbose: bool = False) -> tuple[dict, dict]:
346
+ """Check both APIs with progress spinner."""
347
+ cloud_status = None
348
+ local_status = None
349
+
350
+ with Progress(
351
+ SpinnerColumn(),
352
+ TextColumn("[cyan]{task.description}"),
353
+ console=console,
354
+ transient=True,
355
+ ) as progress:
356
+ # Check Cloud API
357
+ task = progress.add_task("Checking Cloud API...", total=None)
358
+ cloud_status = await check_site_manager_api(verbose=verbose)
359
+ progress.remove_task(task)
360
+
361
+ # Check Local Controller
362
+ if settings.controller_url:
363
+ task = progress.add_task("Checking Local Controller...", total=None)
364
+ local_status = await check_local_controller(verbose=verbose)
365
+ progress.remove_task(task)
366
+ else:
367
+ local_status = await check_local_controller(verbose=verbose)
368
+
369
+ return cloud_status, local_status
370
+
371
+
372
+ @app.callback(invoke_without_command=True)
373
+ def status(
374
+ ctx: typer.Context,
375
+ output: Annotated[
376
+ OutputFormat,
377
+ typer.Option(
378
+ "--output",
379
+ "-o",
380
+ help="Output format: table or json",
381
+ ),
382
+ ] = OutputFormat.TABLE,
383
+ verbose: Annotated[
384
+ bool,
385
+ typer.Option(
386
+ "--verbose",
387
+ "-v",
388
+ help="Show detailed information including full API key",
389
+ ),
390
+ ] = False,
391
+ ) -> None:
392
+ """Check API connectivity and authentication status."""
393
+
394
+ # Run async checks with spinner
395
+ cloud_status, local_status = asyncio.run(check_with_spinner(verbose=verbose))
396
+
397
+ if output == OutputFormat.JSON:
398
+ result = {
399
+ "cloud_api": cloud_status,
400
+ "local_controller": local_status,
401
+ }
402
+ output_json(result, verbose=verbose)
403
+ else:
404
+ print_status_table(cloud_status, local_status)
405
+
406
+ # Exit with error code if neither is authenticated
407
+ cloud_ok = cloud_status["authentication"] == "Valid"
408
+ local_ok = local_status["authentication"] == "Valid"
409
+ if not cloud_ok and not local_ok:
410
+ raise typer.Exit(1)
@@ -0,0 +1,13 @@
1
+ """Version command - display CLI version."""
2
+
3
+ import typer
4
+
5
+ from ui_cli import __version__
6
+
7
+ app = typer.Typer(help="Display CLI version")
8
+
9
+
10
+ @app.callback(invoke_without_command=True)
11
+ def version() -> None:
12
+ """Display the CLI version."""
13
+ typer.echo(f"ui-cli version {__version__}")
ui_cli/config.py ADDED
@@ -0,0 +1,106 @@
1
+ """Configuration management using pydantic-settings."""
2
+
3
+ import os
4
+ from pathlib import Path
5
+
6
+ from pydantic import Field
7
+ from pydantic_settings import BaseSettings, SettingsConfigDict
8
+
9
+
10
+ def _get_config_files() -> tuple[str, ...]:
11
+ """Get config files in priority order (first found wins).
12
+
13
+ Priority:
14
+ 1. XDG config (~/.config/ui-cli/config)
15
+ 2. Home dotfile (~/.ui-cli.env)
16
+ 3. Project local (.env)
17
+ """
18
+ xdg_config = os.environ.get("XDG_CONFIG_HOME", str(Path.home() / ".config"))
19
+ candidates = [
20
+ Path(xdg_config) / "ui-cli" / "config",
21
+ Path.home() / ".ui-cli.env",
22
+ Path(".env"),
23
+ ]
24
+ # Return all existing files (pydantic-settings will use first match per variable)
25
+ return tuple(str(p) for p in candidates if p.exists())
26
+
27
+
28
+ class Settings(BaseSettings):
29
+ """Application settings loaded from environment variables."""
30
+
31
+ model_config = SettingsConfigDict(
32
+ env_prefix="UNIFI_",
33
+ env_file=_get_config_files(),
34
+ env_file_encoding="utf-8",
35
+ extra="ignore",
36
+ )
37
+
38
+ # Site Manager API (cloud)
39
+ api_key: str = Field(
40
+ default="",
41
+ description="UniFi API key for authentication",
42
+ )
43
+ api_url: str = Field(
44
+ default="https://api.ui.com/v1",
45
+ description="UniFi API base URL",
46
+ )
47
+ timeout: int = Field(
48
+ default=15,
49
+ description="Request timeout in seconds",
50
+ )
51
+
52
+ # Local Controller API
53
+ controller_url: str = Field(
54
+ default="",
55
+ description="Local controller URL (e.g., https://192.168.1.1)",
56
+ )
57
+ controller_username: str = Field(
58
+ default="",
59
+ description="Local controller username",
60
+ )
61
+ controller_password: str = Field(
62
+ default="",
63
+ description="Local controller password",
64
+ )
65
+ controller_api_key: str = Field(
66
+ default="",
67
+ description="Local controller API key (UniFi OS Dashboard → Settings → Admins → API Keys). Env: UNIFI_CONTROLLER_API_KEY",
68
+ )
69
+ controller_site: str = Field(
70
+ default="default",
71
+ description="Site name for local controller",
72
+ )
73
+ controller_verify_ssl: bool = Field(
74
+ default=False,
75
+ description="Verify SSL certificates (disable for self-signed)",
76
+ )
77
+
78
+ @property
79
+ def is_configured(self) -> bool:
80
+ """Check if Site Manager API key is configured."""
81
+ return bool(self.api_key)
82
+
83
+ @property
84
+ def is_local_configured(self) -> bool:
85
+ """Check if local controller is configured.
86
+
87
+ Accepts either:
88
+ - controller_url + controller_api_key (API key auth, UniFi OS >= 5.0.3)
89
+ - controller_url + controller_username + controller_password (legacy auth)
90
+ """
91
+ if not self.controller_url:
92
+ return False
93
+ if self.controller_api_key:
94
+ return True
95
+ return bool(self.controller_username and self.controller_password)
96
+
97
+ @property
98
+ def session_file(self) -> Path:
99
+ """Path to session storage file."""
100
+ config_dir = Path.home() / ".config" / "ui-cli"
101
+ config_dir.mkdir(parents=True, exist_ok=True)
102
+ return config_dir / "session.json"
103
+
104
+
105
+ # Global settings instance
106
+ settings = Settings()