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,503 @@
1
+ """Client groups management commands."""
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ from ui_cli.groups import GroupManager, AutoGroupRules
7
+ from ui_cli.output import output_table, output_json, output_csv
8
+
9
+ app = typer.Typer(
10
+ name="groups",
11
+ help="Manage client groups for bulk actions",
12
+ no_args_is_help=True,
13
+ )
14
+
15
+ console = Console()
16
+
17
+ # Column definitions for output
18
+ GROUP_COLUMNS = [
19
+ ("name", "Name"),
20
+ ("type", "Type"),
21
+ ("member_count", "Members"),
22
+ ("description", "Description"),
23
+ ]
24
+
25
+ MEMBER_COLUMNS = [
26
+ ("alias", "Alias"),
27
+ ("mac", "MAC"),
28
+ ]
29
+
30
+
31
+ # -----------------------------------------------------------------------------
32
+ # Group CRUD Commands
33
+ # -----------------------------------------------------------------------------
34
+
35
+
36
+ @app.command("list")
37
+ def list_groups(
38
+ output: str = typer.Option("table", "-o", "--output", help="Output format"),
39
+ ) -> None:
40
+ """List all groups."""
41
+ gm = GroupManager()
42
+ groups = gm.list_groups()
43
+
44
+ data = []
45
+ for slug, group in groups:
46
+ member_count: str | int = len(group.members) if group.members else 0
47
+ if group.type == "auto":
48
+ member_count = "(auto)"
49
+ data.append({
50
+ "slug": slug,
51
+ "name": group.name,
52
+ "type": group.type,
53
+ "member_count": member_count,
54
+ "description": group.description or "",
55
+ })
56
+
57
+ if output == "json":
58
+ output_json(data)
59
+ elif output == "csv":
60
+ output_csv(data, GROUP_COLUMNS)
61
+ else:
62
+ if not data:
63
+ console.print("[dim]No groups defined. Create one with:[/dim]")
64
+ console.print(" ./ui groups create \"My Group\"")
65
+ return
66
+ output_table(data, GROUP_COLUMNS)
67
+
68
+
69
+ # Alias for list
70
+ @app.command("ls", hidden=True)
71
+ def list_groups_alias(
72
+ output: str = typer.Option("table", "-o", "--output"),
73
+ ) -> None:
74
+ """List all groups (alias for list)."""
75
+ list_groups(output=output)
76
+
77
+
78
+ @app.command("create")
79
+ def create_group(
80
+ name: str = typer.Argument(..., help="Group name"),
81
+ description: str = typer.Option(None, "-d", "--description", help="Description"),
82
+ ) -> None:
83
+ """Create a static group."""
84
+ gm = GroupManager()
85
+ try:
86
+ slug, group = gm.create_group(name, description)
87
+ console.print(f"[green]Created group:[/green] {group.name} [dim]({slug})[/dim]")
88
+ except ValueError as e:
89
+ console.print(f"[red]Error:[/red] {e}")
90
+ raise typer.Exit(1)
91
+
92
+
93
+ @app.command("show")
94
+ def show_group(
95
+ name: str = typer.Argument(..., help="Group name or slug"),
96
+ output: str = typer.Option("table", "-o", "--output", help="Output format"),
97
+ ) -> None:
98
+ """Show group details and members."""
99
+ gm = GroupManager()
100
+ result = gm.get_group(name)
101
+
102
+ if not result:
103
+ console.print(f"[red]Error:[/red] Group '{name}' not found")
104
+ raise typer.Exit(1)
105
+
106
+ slug, group = result
107
+
108
+ if output == "json":
109
+ output_json({"slug": slug, **group.model_dump()})
110
+ return
111
+
112
+ # Header info
113
+ console.print(f"\n[bold]Group:[/bold] {group.name}")
114
+ console.print(f"[bold]Type:[/bold] {group.type}")
115
+ if group.description:
116
+ console.print(f"[bold]Description:[/bold] {group.description}")
117
+
118
+ if group.type == "auto" and group.rules:
119
+ console.print("\n[bold]Rules:[/bold]")
120
+ rules = group.rules.model_dump(exclude_none=True)
121
+ for key, values in rules.items():
122
+ console.print(f" {key}: {', '.join(values)}")
123
+ console.print("\n[dim]Use './ui lo clients list -g {slug}' to see matching clients[/dim]")
124
+
125
+ if group.type == "static":
126
+ console.print(f"[bold]Members:[/bold] {len(group.members or [])}")
127
+ console.print(f"[bold]Created:[/bold] {group.created_at:%Y-%m-%d %H:%M}")
128
+ console.print(f"[bold]Updated:[/bold] {group.updated_at:%Y-%m-%d %H:%M}")
129
+
130
+ if group.members:
131
+ console.print()
132
+ member_data = [
133
+ {"alias": m.alias or "-", "mac": m.mac}
134
+ for m in group.members
135
+ ]
136
+ output_table(member_data, MEMBER_COLUMNS)
137
+ else:
138
+ console.print("\n[dim]No members. Add with:[/dim]")
139
+ console.print(f" ./ui groups add {slug} <mac-address>")
140
+
141
+
142
+ @app.command("delete")
143
+ def delete_group(
144
+ name: str = typer.Argument(..., help="Group name or slug"),
145
+ yes: bool = typer.Option(False, "-y", "--yes", help="Skip confirmation"),
146
+ ) -> None:
147
+ """Delete a group."""
148
+ gm = GroupManager()
149
+ result = gm.get_group(name)
150
+
151
+ if not result:
152
+ console.print(f"[red]Error:[/red] Group '{name}' not found")
153
+ raise typer.Exit(1)
154
+
155
+ slug, group = result
156
+
157
+ if not yes:
158
+ confirm = typer.confirm(f"Delete group '{group.name}'?")
159
+ if not confirm:
160
+ raise typer.Abort()
161
+
162
+ gm.delete_group(slug)
163
+ console.print(f"[green]Deleted group:[/green] {group.name}")
164
+
165
+
166
+ # Alias for delete
167
+ @app.command("rm", hidden=True)
168
+ def delete_group_alias(
169
+ name: str = typer.Argument(..., help="Group name or slug"),
170
+ yes: bool = typer.Option(False, "-y", "--yes"),
171
+ ) -> None:
172
+ """Delete a group (alias for delete)."""
173
+ delete_group(name=name, yes=yes)
174
+
175
+
176
+ # -----------------------------------------------------------------------------
177
+ # Group Edit Command
178
+ # -----------------------------------------------------------------------------
179
+
180
+
181
+ @app.command("edit")
182
+ def edit_group(
183
+ name: str = typer.Argument(..., help="Group name or slug"),
184
+ new_name: str = typer.Option(None, "-n", "--name", help="New name"),
185
+ description: str = typer.Option(None, "-d", "--description", help="New description"),
186
+ ) -> None:
187
+ """Edit group name or description."""
188
+ gm = GroupManager()
189
+
190
+ if not new_name and description is None:
191
+ console.print("[red]Error:[/red] Specify --name or --description")
192
+ raise typer.Exit(1)
193
+
194
+ try:
195
+ slug, group = gm.update_group(
196
+ name,
197
+ new_name=new_name,
198
+ description=description if description is not None else ...,
199
+ )
200
+ console.print(f"[green]Updated group:[/green] {group.name} [dim]({slug})[/dim]")
201
+ except ValueError as e:
202
+ console.print(f"[red]Error:[/red] {e}")
203
+ raise typer.Exit(1)
204
+
205
+
206
+ # -----------------------------------------------------------------------------
207
+ # Member CRUD Commands
208
+ # -----------------------------------------------------------------------------
209
+
210
+
211
+ @app.command("add")
212
+ def add_members(
213
+ group: str = typer.Argument(..., help="Group name or slug"),
214
+ clients: list[str] = typer.Argument(..., help="Client MAC addresses"),
215
+ alias: str = typer.Option(None, "-a", "--alias", help="Alias (single client only)"),
216
+ ) -> None:
217
+ """Add member(s) to a static group."""
218
+ gm = GroupManager()
219
+ result = gm.get_group(group)
220
+
221
+ if not result:
222
+ console.print(f"[red]Error:[/red] Group '{group}' not found")
223
+ raise typer.Exit(1)
224
+
225
+ _, grp = result
226
+ if grp.type != "static":
227
+ console.print("[red]Error:[/red] Cannot add members to auto groups")
228
+ console.print("[dim]Auto groups use rules to match clients dynamically[/dim]")
229
+ raise typer.Exit(1)
230
+
231
+ if alias and len(clients) > 1:
232
+ console.print("[red]Error:[/red] --alias can only be used with a single client")
233
+ raise typer.Exit(1)
234
+
235
+ for client in clients:
236
+ try:
237
+ mac = GroupManager.normalize_mac(client)
238
+ client_alias = alias if len(clients) == 1 else None
239
+ gm.add_member(group, mac, client_alias)
240
+ display = client_alias or mac
241
+ console.print(f"[green]Added:[/green] {display}")
242
+ except ValueError as e:
243
+ console.print(f"[red]Error:[/red] {e}")
244
+
245
+
246
+ @app.command("remove")
247
+ def remove_members(
248
+ group: str = typer.Argument(..., help="Group name or slug"),
249
+ clients: list[str] = typer.Argument(..., help="Client MACs or aliases"),
250
+ ) -> None:
251
+ """Remove member(s) from a static group."""
252
+ gm = GroupManager()
253
+
254
+ result = gm.get_group(group)
255
+ if not result:
256
+ console.print(f"[red]Error:[/red] Group '{group}' not found")
257
+ raise typer.Exit(1)
258
+
259
+ for client in clients:
260
+ try:
261
+ if gm.remove_member(group, client):
262
+ console.print(f"[green]Removed:[/green] {client}")
263
+ else:
264
+ console.print(f"[yellow]Not found:[/yellow] {client}")
265
+ except ValueError as e:
266
+ console.print(f"[red]Error:[/red] {e}")
267
+
268
+
269
+ @app.command("alias")
270
+ def set_alias(
271
+ group: str = typer.Argument(..., help="Group name or slug"),
272
+ client: str = typer.Argument(..., help="Client MAC or current alias"),
273
+ alias: str = typer.Argument(None, help="New alias (omit to clear)"),
274
+ clear: bool = typer.Option(False, "--clear", help="Clear the alias"),
275
+ ) -> None:
276
+ """Set or clear a member's alias."""
277
+ gm = GroupManager()
278
+
279
+ new_alias = None if clear else alias
280
+
281
+ try:
282
+ if gm.update_member(group, client, alias=new_alias):
283
+ if new_alias:
284
+ console.print(f"[green]Set alias:[/green] {new_alias}")
285
+ else:
286
+ console.print(f"[green]Cleared alias for:[/green] {client}")
287
+ else:
288
+ console.print(f"[red]Error:[/red] Member not found: {client}")
289
+ raise typer.Exit(1)
290
+ except ValueError as e:
291
+ console.print(f"[red]Error:[/red] {e}")
292
+ raise typer.Exit(1)
293
+
294
+
295
+ @app.command("members")
296
+ def list_members(
297
+ group: str = typer.Argument(..., help="Group name or slug"),
298
+ output: str = typer.Option("table", "-o", "--output", help="Output format"),
299
+ ) -> None:
300
+ """List group members."""
301
+ gm = GroupManager()
302
+
303
+ result = gm.get_group(group)
304
+ if not result:
305
+ console.print(f"[red]Error:[/red] Group '{group}' not found")
306
+ raise typer.Exit(1)
307
+
308
+ _, grp = result
309
+
310
+ if grp.type != "static":
311
+ console.print("[yellow]Auto groups have dynamic membership based on rules[/yellow]")
312
+ console.print(f"Use: ./ui lo clients list -g {group}")
313
+ return
314
+
315
+ try:
316
+ members = gm.list_members(group)
317
+ except ValueError as e:
318
+ console.print(f"[red]Error:[/red] {e}")
319
+ raise typer.Exit(1)
320
+
321
+ data = [{"alias": m.alias or "-", "mac": m.mac} for m in members]
322
+
323
+ if output == "json":
324
+ output_json(data)
325
+ elif output == "csv":
326
+ output_csv(data, MEMBER_COLUMNS)
327
+ else:
328
+ if not data:
329
+ console.print("[dim]No members in group[/dim]")
330
+ return
331
+ output_table(data, MEMBER_COLUMNS)
332
+
333
+
334
+ @app.command("clear")
335
+ def clear_members(
336
+ group: str = typer.Argument(..., help="Group name or slug"),
337
+ yes: bool = typer.Option(False, "-y", "--yes", help="Skip confirmation"),
338
+ ) -> None:
339
+ """Remove all members from a static group."""
340
+ gm = GroupManager()
341
+
342
+ result = gm.get_group(group)
343
+ if not result:
344
+ console.print(f"[red]Error:[/red] Group '{group}' not found")
345
+ raise typer.Exit(1)
346
+
347
+ _, grp = result
348
+ member_count = len(grp.members or [])
349
+
350
+ if member_count == 0:
351
+ console.print("[dim]Group has no members[/dim]")
352
+ return
353
+
354
+ if not yes:
355
+ confirm = typer.confirm(f"Remove all {member_count} members from '{grp.name}'?")
356
+ if not confirm:
357
+ raise typer.Abort()
358
+
359
+ try:
360
+ gm.clear_members(group)
361
+ console.print(f"[green]Cleared {member_count} members[/green]")
362
+ except ValueError as e:
363
+ console.print(f"[red]Error:[/red] {e}")
364
+ raise typer.Exit(1)
365
+
366
+
367
+ # -----------------------------------------------------------------------------
368
+ # Auto Group Command
369
+ # -----------------------------------------------------------------------------
370
+
371
+
372
+ @app.command("auto")
373
+ def create_auto_group(
374
+ name: str = typer.Argument(..., help="Group name"),
375
+ vendor: list[str] = typer.Option(None, "--vendor", help="Vendor/OUI patterns"),
376
+ name_pattern: list[str] = typer.Option(None, "--name", help="Client name patterns"),
377
+ hostname: list[str] = typer.Option(None, "--hostname", help="Hostname patterns"),
378
+ network: list[str] = typer.Option(None, "--network", help="Network/SSID names"),
379
+ ip: list[str] = typer.Option(None, "--ip", help="IP patterns/ranges"),
380
+ mac: list[str] = typer.Option(None, "--mac", help="MAC prefix patterns"),
381
+ conn_type: list[str] = typer.Option(None, "--type", help="Connection type: wired, wireless"),
382
+ description: str = typer.Option(None, "-d", "--description", help="Description"),
383
+ dry_run: bool = typer.Option(False, "--dry-run", "--preview", help="Preview without creating"),
384
+ ) -> None:
385
+ """Create an auto group with pattern rules.
386
+
387
+ Auto groups dynamically match clients based on rules.
388
+ Multiple rules of the same type use OR logic.
389
+ Different rule types use AND logic.
390
+
391
+ Pattern syntax:
392
+ - Exact: "Apple"
393
+ - Wildcard: "*phone*", "iPhone*"
394
+ - Regex: "~^iPhone-[0-9]+"
395
+ - Multiple: "Apple,Samsung" (comma-separated)
396
+
397
+ Examples:
398
+ ./ui groups auto "Apple Devices" --vendor "Apple"
399
+ ./ui groups auto "IoT" --vendor "Philips,LIFX,Ring"
400
+ ./ui groups auto "Cameras" --name "*camera*,*cam*"
401
+ ./ui groups auto "Guest WiFi" --network "Guest"
402
+ ./ui groups auto "Servers" --ip "192.168.1.100-200"
403
+ """
404
+ # Build rules
405
+ rules = AutoGroupRules(
406
+ vendor=vendor,
407
+ name=name_pattern,
408
+ hostname=hostname,
409
+ network=network,
410
+ ip=ip,
411
+ mac=mac,
412
+ conn_type=conn_type,
413
+ )
414
+
415
+ # Check at least one rule specified
416
+ if not any([vendor, name_pattern, hostname, network, ip, mac, conn_type]):
417
+ console.print("[red]Error:[/red] Specify at least one rule")
418
+ console.print("[dim]Available: --vendor, --name, --hostname, --network, --ip, --mac, --type[/dim]")
419
+ raise typer.Exit(1)
420
+
421
+ gm = GroupManager()
422
+
423
+ # Show rules
424
+ rules_dict = rules.model_dump(exclude_none=True)
425
+
426
+ if dry_run:
427
+ console.print(f"\n[bold]Preview:[/bold] Auto group '{name}'")
428
+ if description:
429
+ console.print(f"[bold]Description:[/bold] {description}")
430
+ console.print("\n[bold]Rules:[/bold]")
431
+ for key, val in rules_dict.items():
432
+ console.print(f" {key}: {', '.join(val)}")
433
+ console.print("\n[yellow]Dry run - group not created[/yellow]")
434
+ console.print("Run without --dry-run to create")
435
+ return
436
+
437
+ try:
438
+ slug, group = gm.create_group(name, description, "auto", rules)
439
+ console.print(f"[green]Created auto group:[/green] {group.name} [dim]({slug})[/dim]")
440
+ console.print("\n[bold]Rules:[/bold]")
441
+ for key, val in rules_dict.items():
442
+ console.print(f" {key}: {', '.join(val)}")
443
+ console.print(f"\n[dim]View matches: ./ui lo clients list -g {slug}[/dim]")
444
+ except ValueError as e:
445
+ console.print(f"[red]Error:[/red] {e}")
446
+ raise typer.Exit(1)
447
+
448
+
449
+ # -----------------------------------------------------------------------------
450
+ # Import/Export Commands
451
+ # -----------------------------------------------------------------------------
452
+
453
+
454
+ @app.command("export")
455
+ def export_groups(
456
+ output_file: str = typer.Option(None, "-o", "--output", help="Output file path"),
457
+ ) -> None:
458
+ """Export all groups to JSON."""
459
+ gm = GroupManager()
460
+ data = gm.export_groups()
461
+
462
+ if output_file:
463
+ from pathlib import Path
464
+ Path(output_file).write_text(
465
+ __import__("json").dumps(data, indent=2, default=str)
466
+ )
467
+ console.print(f"[green]Exported to:[/green] {output_file}")
468
+ else:
469
+ output_json(data)
470
+
471
+
472
+ @app.command("import")
473
+ def import_groups(
474
+ input_file: str = typer.Argument(..., help="JSON file to import"),
475
+ replace: bool = typer.Option(False, "--replace", help="Replace all existing groups"),
476
+ yes: bool = typer.Option(False, "-y", "--yes", help="Skip confirmation"),
477
+ ) -> None:
478
+ """Import groups from JSON file."""
479
+ from pathlib import Path
480
+ import json
481
+
482
+ path = Path(input_file)
483
+ if not path.exists():
484
+ console.print(f"[red]Error:[/red] File not found: {input_file}")
485
+ raise typer.Exit(1)
486
+
487
+ try:
488
+ data = json.loads(path.read_text())
489
+ except json.JSONDecodeError as e:
490
+ console.print(f"[red]Error:[/red] Invalid JSON: {e}")
491
+ raise typer.Exit(1)
492
+
493
+ group_count = len(data.get("groups", {}))
494
+
495
+ if replace and not yes:
496
+ confirm = typer.confirm(f"Replace all groups with {group_count} from file?")
497
+ if not confirm:
498
+ raise typer.Abort()
499
+
500
+ gm = GroupManager()
501
+ imported = gm.import_groups(data, replace=replace)
502
+ action = "Replaced with" if replace else "Imported"
503
+ console.print(f"[green]{action} {imported} groups[/green]")
@@ -0,0 +1,114 @@
1
+ """Host management commands."""
2
+
3
+ import asyncio
4
+ from typing import Annotated
5
+
6
+ import typer
7
+
8
+ from ui_cli.client import APIError, UniFiClient
9
+ from ui_cli.output import OutputFormat, print_error, render_output
10
+
11
+ app = typer.Typer(help="Manage UniFi hosts (consoles and controllers)")
12
+
13
+ # Column definitions for hosts table
14
+ HOST_COLUMNS = [
15
+ ("id", "ID"),
16
+ ("reportedState.hostname", "Hostname"),
17
+ ("type", "Type"),
18
+ ("ipAddress", "IP Address"),
19
+ ("reportedState.version", "Version"),
20
+ ("owner", "Owner"),
21
+ ("isBlocked", "Blocked"),
22
+ ]
23
+
24
+
25
+ @app.command("list")
26
+ def list_hosts(
27
+ output: Annotated[
28
+ OutputFormat,
29
+ typer.Option(
30
+ "--output",
31
+ "-o",
32
+ help="Output format: table, json, or csv",
33
+ ),
34
+ ] = OutputFormat.TABLE,
35
+ verbose: Annotated[
36
+ bool,
37
+ typer.Option(
38
+ "--verbose",
39
+ "-v",
40
+ help="Show detailed request/response information",
41
+ ),
42
+ ] = False,
43
+ ) -> None:
44
+ """List all hosts (consoles/controllers) associated with your account."""
45
+
46
+ async def _list() -> list:
47
+ client = UniFiClient()
48
+ return await client.list_hosts()
49
+
50
+ try:
51
+ hosts = asyncio.run(_list())
52
+
53
+ if verbose:
54
+ typer.echo(f"Found {len(hosts)} host(s)")
55
+
56
+ render_output(
57
+ data=hosts,
58
+ output_format=output,
59
+ columns=HOST_COLUMNS,
60
+ title="UniFi Hosts",
61
+ verbose=verbose,
62
+ )
63
+ except APIError as e:
64
+ print_error(e.message)
65
+ raise typer.Exit(1)
66
+
67
+
68
+ @app.command("get")
69
+ def get_host(
70
+ host_id: Annotated[
71
+ str,
72
+ typer.Argument(help="The unique identifier of the host"),
73
+ ],
74
+ output: Annotated[
75
+ OutputFormat,
76
+ typer.Option(
77
+ "--output",
78
+ "-o",
79
+ help="Output format: table, json, or csv",
80
+ ),
81
+ ] = OutputFormat.TABLE,
82
+ verbose: Annotated[
83
+ bool,
84
+ typer.Option(
85
+ "--verbose",
86
+ "-v",
87
+ help="Show detailed request/response information",
88
+ ),
89
+ ] = False,
90
+ ) -> None:
91
+ """Get detailed information about a specific host."""
92
+
93
+ async def _get() -> dict:
94
+ client = UniFiClient()
95
+ return await client.get_host(host_id)
96
+
97
+ try:
98
+ host = asyncio.run(_get())
99
+
100
+ if not host:
101
+ print_error(f"Host '{host_id}' not found")
102
+ raise typer.Exit(1)
103
+
104
+ render_output(
105
+ data=host,
106
+ output_format=output,
107
+ columns=HOST_COLUMNS,
108
+ title=f"Host: {host_id}",
109
+ verbose=verbose,
110
+ is_single=True,
111
+ )
112
+ except APIError as e:
113
+ print_error(e.message)
114
+ raise typer.Exit(1)