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,426 @@
1
+ """Network configuration commands for local controller."""
2
+
3
+ import asyncio
4
+ from typing import Annotated, Any
5
+
6
+ import typer
7
+
8
+ from ui_cli.local_client import LocalAPIError, UniFiLocalClient
9
+ from ui_cli.output import (
10
+ OutputFormat,
11
+ console,
12
+ output_csv,
13
+ output_json,
14
+ print_error,
15
+ )
16
+
17
+ app = typer.Typer(name="networks", help="Network configuration", no_args_is_help=True)
18
+
19
+
20
+ def format_dhcp_range(network: dict[str, Any]) -> str:
21
+ """Format DHCP range for display."""
22
+ if not network.get("dhcpd_enabled", False):
23
+ return "Disabled"
24
+
25
+ start = network.get("dhcpd_start", "")
26
+ stop = network.get("dhcpd_stop", "")
27
+
28
+ if start and stop:
29
+ # Extract last octet for compact display
30
+ start_last = start.split(".")[-1] if "." in start else start
31
+ stop_last = stop.split(".")[-1] if "." in stop else stop
32
+ return f".{start_last} - .{stop_last}"
33
+
34
+ return "Enabled"
35
+
36
+
37
+ def format_subnet(network: dict[str, Any]) -> str:
38
+ """Format subnet for display."""
39
+ subnet = network.get("ip_subnet", "")
40
+ if subnet:
41
+ return subnet
42
+ return "-"
43
+
44
+
45
+ def get_network_purpose(network: dict[str, Any]) -> str:
46
+ """Get network purpose/type."""
47
+ purpose = network.get("purpose", "")
48
+ if purpose:
49
+ return purpose
50
+ # Fallback to network type
51
+ return network.get("networkgroup", "LAN")
52
+
53
+
54
+ @app.command("list")
55
+ def list_networks(
56
+ output: Annotated[
57
+ OutputFormat,
58
+ typer.Option("--output", "-o", help="Output format"),
59
+ ] = OutputFormat.TABLE,
60
+ verbose: Annotated[
61
+ bool,
62
+ typer.Option("--verbose", "-v", help="Show additional details"),
63
+ ] = False,
64
+ ) -> None:
65
+ """List all networks."""
66
+ from ui_cli.commands.local.utils import run_with_spinner
67
+
68
+ async def _list():
69
+ client = UniFiLocalClient()
70
+ return await client.get_networks()
71
+
72
+ try:
73
+ networks = run_with_spinner(_list(), "Fetching networks...")
74
+ except LocalAPIError as e:
75
+ print_error(str(e))
76
+ raise typer.Exit(1)
77
+
78
+ if not networks:
79
+ console.print("[dim]No networks found[/dim]")
80
+ return
81
+
82
+ if output == OutputFormat.JSON:
83
+ output_json(networks)
84
+ elif output == OutputFormat.CSV:
85
+ columns = [
86
+ ("_id", "ID"),
87
+ ("name", "Name"),
88
+ ("vlan", "VLAN"),
89
+ ("ip_subnet", "Subnet"),
90
+ ("purpose", "Purpose"),
91
+ ("dhcpd_enabled", "DHCP"),
92
+ ]
93
+ csv_data = []
94
+ for n in networks:
95
+ csv_data.append({
96
+ "_id": n.get("_id", ""),
97
+ "name": n.get("name", ""),
98
+ "vlan": n.get("vlan", "1"),
99
+ "ip_subnet": n.get("ip_subnet", ""),
100
+ "purpose": get_network_purpose(n),
101
+ "dhcpd_enabled": "Yes" if n.get("dhcpd_enabled") else "No",
102
+ })
103
+ output_csv(csv_data, columns)
104
+ else:
105
+ from rich.table import Table
106
+
107
+ table = Table(title="Networks", show_header=True, header_style="bold cyan")
108
+ table.add_column("ID", style="dim")
109
+ table.add_column("Name")
110
+ table.add_column("VLAN")
111
+ table.add_column("Subnet")
112
+ table.add_column("DHCP")
113
+ table.add_column("Purpose")
114
+ if verbose:
115
+ table.add_column("Gateway")
116
+ table.add_column("Domain")
117
+
118
+ for n in networks:
119
+ network_id = n.get("_id", "")
120
+ name = n.get("name", "")
121
+ vlan = str(n.get("vlan", "1"))
122
+ subnet = format_subnet(n)
123
+ dhcp = format_dhcp_range(n)
124
+ purpose = get_network_purpose(n)
125
+
126
+ if verbose:
127
+ gateway = n.get("dhcpd_gateway", n.get("ip_subnet", "").split("/")[0] if n.get("ip_subnet") else "")
128
+ domain = n.get("domain_name", "-")
129
+ table.add_row(network_id, name, vlan, subnet, dhcp, purpose, gateway, domain)
130
+ else:
131
+ table.add_row(network_id, name, vlan, subnet, dhcp, purpose)
132
+
133
+ console.print(table)
134
+ console.print(f"\n[dim]{len(networks)} network(s)[/dim]")
135
+
136
+
137
+ @app.command("get")
138
+ def get_network(
139
+ network_id: Annotated[str, typer.Argument(help="Network ID or name")],
140
+ output: Annotated[
141
+ OutputFormat,
142
+ typer.Option("--output", "-o", help="Output format"),
143
+ ] = OutputFormat.TABLE,
144
+ ) -> None:
145
+ """Get network details."""
146
+ from ui_cli.commands.local.utils import run_with_spinner
147
+
148
+ async def _get():
149
+ client = UniFiLocalClient()
150
+ networks = await client.get_networks()
151
+
152
+ # Find by ID or name
153
+ for n in networks:
154
+ if n.get("_id") == network_id or n.get("name", "").lower() == network_id.lower():
155
+ return n
156
+
157
+ # Partial name match
158
+ for n in networks:
159
+ if network_id.lower() in n.get("name", "").lower():
160
+ return n
161
+
162
+ return None
163
+
164
+ try:
165
+ network = run_with_spinner(_get(), "Finding network...")
166
+ except LocalAPIError as e:
167
+ print_error(str(e))
168
+ raise typer.Exit(1)
169
+
170
+ if not network:
171
+ print_error(f"Network '{network_id}' not found")
172
+ raise typer.Exit(1)
173
+
174
+ if output == OutputFormat.JSON:
175
+ output_json(network)
176
+ return
177
+
178
+ # Table output
179
+ from rich.table import Table
180
+
181
+ name = network.get("name", "Unknown")
182
+ console.print()
183
+ console.print(f"[bold cyan]Network: {name}[/bold cyan]")
184
+ console.print("─" * 40)
185
+ console.print()
186
+
187
+ table = Table(show_header=False, box=None, padding=(0, 2))
188
+ table.add_column("Key", style="dim")
189
+ table.add_column("Value")
190
+
191
+ table.add_row("ID:", network.get("_id", ""))
192
+ table.add_row("VLAN:", str(network.get("vlan", "1")))
193
+ table.add_row("Purpose:", get_network_purpose(network))
194
+ table.add_row("", "")
195
+
196
+ # Subnet info
197
+ subnet = network.get("ip_subnet", "")
198
+ if subnet:
199
+ table.add_row("Subnet:", subnet)
200
+ gateway = subnet.split("/")[0] if "/" in subnet else ""
201
+ if gateway:
202
+ # Replace last octet with .1 for gateway
203
+ parts = gateway.rsplit(".", 1)
204
+ if len(parts) == 2:
205
+ gateway = f"{parts[0]}.1"
206
+ table.add_row("Gateway:", network.get("dhcpd_gateway", gateway))
207
+
208
+ table.add_row("", "")
209
+
210
+ # DHCP info
211
+ if network.get("dhcpd_enabled"):
212
+ table.add_row("DHCP:", "[green]Enabled[/green]")
213
+ start = network.get("dhcpd_start", "")
214
+ stop = network.get("dhcpd_stop", "")
215
+ if start and stop:
216
+ table.add_row("Range:", f"{start} - {stop}")
217
+ lease = network.get("dhcpd_leasetime", 86400)
218
+ table.add_row("Lease:", f"{lease // 3600}h")
219
+
220
+ # DNS (dhcpd_dns_{1..4}, gated by dhcpd_dns_enabled)
221
+ if network.get("dhcpd_dns_enabled"):
222
+ dns_parts = [
223
+ network.get(f"dhcpd_dns_{i}", "") for i in (1, 2, 3, 4)
224
+ ]
225
+ dns_parts = [d for d in dns_parts if d]
226
+ if dns_parts:
227
+ table.add_row("DNS:", ", ".join(dns_parts))
228
+ else:
229
+ table.add_row("DNS:", "[dim]custom enabled, no servers set[/dim]")
230
+ else:
231
+ table.add_row("DNS:", "[dim]auto (gateway)[/dim]")
232
+ else:
233
+ table.add_row("DHCP:", "[dim]Disabled[/dim]")
234
+
235
+ table.add_row("", "")
236
+
237
+ # Additional settings
238
+ if network.get("igmp_snooping"):
239
+ table.add_row("IGMP Snooping:", "Yes")
240
+
241
+ if network.get("dhcpguard_enabled"):
242
+ table.add_row("DHCP Guard:", "Yes")
243
+
244
+ domain = network.get("domain_name")
245
+ if domain:
246
+ table.add_row("Domain:", domain)
247
+
248
+ # Network isolation
249
+ if network.get("purpose") == "guest":
250
+ table.add_row("Guest Network:", "Yes")
251
+ if network.get("networkgroup") == "LAN":
252
+ table.add_row("Internet Only:", "Yes")
253
+
254
+ console.print(table)
255
+ console.print()
256
+
257
+
258
+ @app.command("update")
259
+ def update_network(
260
+ network_ids: Annotated[
261
+ list[str],
262
+ typer.Argument(help="Network ID(s) or name(s) — pass multiple to update in one call"),
263
+ ],
264
+ dhcp_start: Annotated[
265
+ str | None,
266
+ typer.Option("--dhcp-start", help="DHCP range start (last octet or full IP)"),
267
+ ] = None,
268
+ dhcp_stop: Annotated[
269
+ str | None,
270
+ typer.Option("--dhcp-stop", help="DHCP range stop (last octet or full IP)"),
271
+ ] = None,
272
+ dns1: Annotated[
273
+ str | None,
274
+ typer.Option("--dns1", help="Primary DHCP DNS server (dhcpd_dns_1)"),
275
+ ] = None,
276
+ dns2: Annotated[
277
+ str | None,
278
+ typer.Option("--dns2", help="Secondary DHCP DNS server (dhcpd_dns_2)"),
279
+ ] = None,
280
+ dns3: Annotated[
281
+ str | None,
282
+ typer.Option("--dns3", help="Tertiary DHCP DNS server (dhcpd_dns_3)"),
283
+ ] = None,
284
+ dns4: Annotated[
285
+ str | None,
286
+ typer.Option("--dns4", help="Quaternary DHCP DNS server (dhcpd_dns_4)"),
287
+ ] = None,
288
+ no_dns: Annotated[
289
+ bool,
290
+ typer.Option("--no-dns", help="Disable custom DHCP DNS (fall back to auto/gateway)"),
291
+ ] = False,
292
+ output: Annotated[
293
+ OutputFormat,
294
+ typer.Option("--output", "-o", help="Output format"),
295
+ ] = OutputFormat.TABLE,
296
+ ) -> None:
297
+ """Update network settings (DHCP range, DHCP DNS servers)."""
298
+ from ui_cli.commands.local.utils import run_with_spinner
299
+ from ui_cli.output import print_success
300
+
301
+ dns_requested = any(v is not None for v in (dns1, dns2, dns3, dns4)) or no_dns
302
+ dhcp_requested = dhcp_start is not None or dhcp_stop is not None
303
+
304
+ if not dhcp_requested and not dns_requested:
305
+ print_error(
306
+ "At least one option required (--dhcp-start, --dhcp-stop, --dns1..--dns4, --no-dns)"
307
+ )
308
+ raise typer.Exit(1)
309
+
310
+ if no_dns and any(v is not None for v in (dns1, dns2, dns3, dns4)):
311
+ print_error("--no-dns cannot be combined with --dns1/--dns2/--dns3/--dns4")
312
+ raise typer.Exit(1)
313
+
314
+ def _resolve(networks: list[dict[str, Any]], needle: str) -> dict[str, Any] | None:
315
+ for n in networks:
316
+ if n.get("_id") == needle or n.get("name", "").lower() == needle.lower():
317
+ return n
318
+ for n in networks:
319
+ if needle.lower() in n.get("name", "").lower():
320
+ return n
321
+ return None
322
+
323
+ async def _update_one(
324
+ client: UniFiLocalClient,
325
+ networks: list[dict[str, Any]],
326
+ needle: str,
327
+ ) -> tuple[dict[str, Any] | None, str | None]:
328
+ network = _resolve(networks, needle)
329
+ if not network:
330
+ return None, f"Network '{needle}' not found"
331
+
332
+ net_id = network["_id"]
333
+ subnet = network.get("ip_subnet", "")
334
+ payload: dict[str, Any] = {"_id": net_id}
335
+
336
+ if dhcp_requested:
337
+ if not network.get("dhcpd_enabled"):
338
+ return None, f"Network '{network.get('name')}' has DHCP disabled"
339
+ if not subnet:
340
+ return None, f"Network '{network.get('name')}' has no subnet configured"
341
+ prefix = ".".join(subnet.split("/")[0].split(".")[:3])
342
+
343
+ if dhcp_start is not None:
344
+ payload["dhcpd_start"] = (
345
+ f"{prefix}.{dhcp_start}" if "." not in dhcp_start else dhcp_start
346
+ )
347
+ if dhcp_stop is not None:
348
+ payload["dhcpd_stop"] = (
349
+ f"{prefix}.{dhcp_stop}" if "." not in dhcp_stop else dhcp_stop
350
+ )
351
+
352
+ if dns_requested:
353
+ if no_dns:
354
+ payload["dhcpd_dns_enabled"] = False
355
+ payload.update({
356
+ "dhcpd_dns_1": "",
357
+ "dhcpd_dns_2": "",
358
+ "dhcpd_dns_3": "",
359
+ "dhcpd_dns_4": "",
360
+ })
361
+ else:
362
+ payload["dhcpd_dns_enabled"] = True
363
+ if dns1 is not None:
364
+ payload["dhcpd_dns_1"] = dns1
365
+ if dns2 is not None:
366
+ payload["dhcpd_dns_2"] = dns2
367
+ if dns3 is not None:
368
+ payload["dhcpd_dns_3"] = dns3
369
+ if dns4 is not None:
370
+ payload["dhcpd_dns_4"] = dns4
371
+
372
+ updated = await client.update_network(net_id, payload)
373
+ return updated, None
374
+
375
+ async def _update_all():
376
+ client = UniFiLocalClient()
377
+ networks = await client.get_networks()
378
+ results: list[tuple[str, dict[str, Any] | None, str | None]] = []
379
+ for needle in network_ids:
380
+ result, err = await _update_one(client, networks, needle)
381
+ results.append((needle, result, err))
382
+ return results
383
+
384
+ try:
385
+ results = run_with_spinner(_update_all(), "Updating networks...")
386
+ except LocalAPIError as e:
387
+ print_error(str(e))
388
+ raise typer.Exit(1)
389
+
390
+ if output == OutputFormat.JSON:
391
+ output_json([
392
+ {"network": needle, "error": err, "result": result}
393
+ for needle, result, err in results
394
+ ])
395
+ exit_code = 1 if any(err for _, _, err in results) else 0
396
+ raise typer.Exit(exit_code)
397
+
398
+ exit_code = 0
399
+ for needle, result, err in results:
400
+ if err:
401
+ print_error(f"[{needle}] {err}")
402
+ exit_code = 1
403
+ continue
404
+ if not result:
405
+ print_error(f"[{needle}] Update failed - no response from controller")
406
+ exit_code = 1
407
+ continue
408
+
409
+ name = result.get("name", needle)
410
+ print_success(f"Updated network '{name}'")
411
+ if dhcp_requested:
412
+ console.print(
413
+ f" DHCP Range: {result.get('dhcpd_start', '')} - {result.get('dhcpd_stop', '')}"
414
+ )
415
+ if dns_requested:
416
+ if result.get("dhcpd_dns_enabled"):
417
+ dns_parts = [
418
+ result.get(f"dhcpd_dns_{i}", "") for i in (1, 2, 3, 4)
419
+ ]
420
+ dns_parts = [d for d in dns_parts if d]
421
+ console.print(f" DNS: {', '.join(dns_parts) if dns_parts else '(enabled, empty)'}")
422
+ else:
423
+ console.print(" DNS: auto (custom disabled)")
424
+
425
+ if exit_code:
426
+ raise typer.Exit(exit_code)
@@ -0,0 +1,153 @@
1
+ """Port forwarding commands for local controller."""
2
+
3
+ import asyncio
4
+ from typing import Annotated, Any
5
+
6
+ import typer
7
+
8
+ from ui_cli.local_client import LocalAPIError, UniFiLocalClient
9
+ from ui_cli.output import (
10
+ OutputFormat,
11
+ console,
12
+ output_csv,
13
+ output_json,
14
+ print_error,
15
+ )
16
+
17
+ app = typer.Typer(name="portfwd", help="Port forwarding rules", no_args_is_help=True)
18
+
19
+
20
+ def format_protocol(rule: dict[str, Any]) -> str:
21
+ """Format protocol for display."""
22
+ proto = rule.get("proto", "tcp_udp")
23
+ if proto == "tcp_udp":
24
+ return "TCP/UDP"
25
+ return proto.upper()
26
+
27
+
28
+ def format_source(rule: dict[str, Any]) -> str:
29
+ """Format source (WAN) side."""
30
+ port = rule.get("dst_port", "")
31
+ src = rule.get("src", "any")
32
+
33
+ if src and src != "any":
34
+ return f"{src}:{port}"
35
+ return f"*:{port}"
36
+
37
+
38
+ def format_destination(rule: dict[str, Any]) -> str:
39
+ """Format destination (LAN) side."""
40
+ fwd_ip = rule.get("fwd", "")
41
+ fwd_port = rule.get("fwd_port", rule.get("dst_port", ""))
42
+
43
+ if fwd_ip:
44
+ return f"{fwd_ip}:{fwd_port}"
45
+ return f"*:{fwd_port}"
46
+
47
+
48
+ def format_interface(rule: dict[str, Any]) -> str:
49
+ """Format WAN interface."""
50
+ pfwd_interface = rule.get("pfwd_interface", "")
51
+ if pfwd_interface == "all":
52
+ return "All WANs"
53
+ elif pfwd_interface:
54
+ return pfwd_interface
55
+ return "WAN"
56
+
57
+
58
+ @app.command("list")
59
+ def list_port_forwards(
60
+ output: Annotated[
61
+ OutputFormat,
62
+ typer.Option("--output", "-o", help="Output format"),
63
+ ] = OutputFormat.TABLE,
64
+ all_rules: Annotated[
65
+ bool,
66
+ typer.Option("--all", "-a", help="Show disabled rules too"),
67
+ ] = True,
68
+ ) -> None:
69
+ """List port forwarding rules."""
70
+ from ui_cli.commands.local.utils import run_with_spinner
71
+
72
+ async def _list():
73
+ client = UniFiLocalClient()
74
+ return await client.get_port_forwards()
75
+
76
+ try:
77
+ rules = run_with_spinner(_list(), "Fetching port forwards...")
78
+ except LocalAPIError as e:
79
+ print_error(str(e))
80
+ raise typer.Exit(1)
81
+
82
+ if not rules:
83
+ console.print("[dim]No port forwarding rules found[/dim]")
84
+ return
85
+
86
+ # Filter disabled if not showing all
87
+ if not all_rules:
88
+ rules = [r for r in rules if r.get("enabled", True)]
89
+ if not rules:
90
+ console.print("[dim]No enabled port forwarding rules[/dim]")
91
+ return
92
+
93
+ # Sort by name
94
+ rules.sort(key=lambda r: r.get("name", "").lower())
95
+
96
+ if output == OutputFormat.JSON:
97
+ output_json(rules)
98
+ elif output == OutputFormat.CSV:
99
+ columns = [
100
+ ("_id", "ID"),
101
+ ("name", "Name"),
102
+ ("enabled", "Enabled"),
103
+ ("proto", "Protocol"),
104
+ ("dst_port", "WAN Port"),
105
+ ("fwd", "LAN IP"),
106
+ ("fwd_port", "LAN Port"),
107
+ ("pfwd_interface", "Interface"),
108
+ ]
109
+ csv_data = []
110
+ for r in rules:
111
+ csv_data.append({
112
+ "_id": r.get("_id", ""),
113
+ "name": r.get("name", ""),
114
+ "enabled": "Yes" if r.get("enabled", True) else "No",
115
+ "proto": format_protocol(r),
116
+ "dst_port": r.get("dst_port", ""),
117
+ "fwd": r.get("fwd", ""),
118
+ "fwd_port": r.get("fwd_port", r.get("dst_port", "")),
119
+ "pfwd_interface": format_interface(r),
120
+ })
121
+ output_csv(csv_data, columns)
122
+ else:
123
+ from rich.table import Table
124
+
125
+ table = Table(title="Port Forwarding Rules", show_header=True, header_style="bold cyan")
126
+ table.add_column("Name")
127
+ table.add_column("Protocol")
128
+ table.add_column("WAN Port")
129
+ table.add_column("→", style="dim")
130
+ table.add_column("LAN Destination")
131
+ table.add_column("Interface")
132
+ table.add_column("Enabled")
133
+
134
+ for r in rules:
135
+ name = r.get("name", "(unnamed)")
136
+ protocol = format_protocol(r)
137
+ wan_port = str(r.get("dst_port", ""))
138
+ destination = format_destination(r)
139
+ interface = format_interface(r)
140
+ enabled = "[green]✓[/green]" if r.get("enabled", True) else "[dim]✗[/dim]"
141
+
142
+ table.add_row(
143
+ name,
144
+ protocol,
145
+ wan_port,
146
+ "→",
147
+ destination,
148
+ interface,
149
+ enabled,
150
+ )
151
+
152
+ console.print(table)
153
+ console.print(f"\n[dim]{len(rules)} rule(s)[/dim]")