container-host-aiops 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.
Files changed (59) hide show
  1. container_host_aiops/__init__.py +9 -0
  2. container_host_aiops/cli/__init__.py +9 -0
  3. container_host_aiops/cli/_common.py +78 -0
  4. container_host_aiops/cli/_root.py +70 -0
  5. container_host_aiops/cli/analyze.py +55 -0
  6. container_host_aiops/cli/container.py +85 -0
  7. container_host_aiops/cli/doctor.py +21 -0
  8. container_host_aiops/cli/image.py +61 -0
  9. container_host_aiops/cli/init.py +126 -0
  10. container_host_aiops/cli/manage.py +192 -0
  11. container_host_aiops/cli/network.py +38 -0
  12. container_host_aiops/cli/overview.py +16 -0
  13. container_host_aiops/cli/secret.py +108 -0
  14. container_host_aiops/cli/stack.py +48 -0
  15. container_host_aiops/cli/system.py +60 -0
  16. container_host_aiops/cli/volume.py +48 -0
  17. container_host_aiops/config.py +198 -0
  18. container_host_aiops/connection.py +227 -0
  19. container_host_aiops/doctor.py +98 -0
  20. container_host_aiops/governance/__init__.py +40 -0
  21. container_host_aiops/governance/audit.py +377 -0
  22. container_host_aiops/governance/budget.py +225 -0
  23. container_host_aiops/governance/decorators.py +474 -0
  24. container_host_aiops/governance/paths.py +23 -0
  25. container_host_aiops/governance/patterns.py +378 -0
  26. container_host_aiops/governance/policy.py +411 -0
  27. container_host_aiops/governance/sanitize.py +39 -0
  28. container_host_aiops/governance/undo.py +218 -0
  29. container_host_aiops/ops/__init__.py +1 -0
  30. container_host_aiops/ops/_metrics.py +83 -0
  31. container_host_aiops/ops/_util.py +90 -0
  32. container_host_aiops/ops/analyses.py +311 -0
  33. container_host_aiops/ops/containers.py +173 -0
  34. container_host_aiops/ops/images.py +124 -0
  35. container_host_aiops/ops/networks.py +53 -0
  36. container_host_aiops/ops/overview.py +45 -0
  37. container_host_aiops/ops/stacks.py +69 -0
  38. container_host_aiops/ops/system.py +163 -0
  39. container_host_aiops/ops/volumes.py +72 -0
  40. container_host_aiops/ops/writes.py +209 -0
  41. container_host_aiops/platform.py +146 -0
  42. container_host_aiops/secretstore.py +302 -0
  43. container_host_aiops-0.1.0.dist-info/METADATA +96 -0
  44. container_host_aiops-0.1.0.dist-info/RECORD +59 -0
  45. container_host_aiops-0.1.0.dist-info/WHEEL +4 -0
  46. container_host_aiops-0.1.0.dist-info/entry_points.txt +3 -0
  47. container_host_aiops-0.1.0.dist-info/licenses/LICENSE +21 -0
  48. mcp_server/__init__.py +1 -0
  49. mcp_server/_shared.py +104 -0
  50. mcp_server/server.py +38 -0
  51. mcp_server/tools/__init__.py +1 -0
  52. mcp_server/tools/analyses.py +93 -0
  53. mcp_server/tools/containers.py +88 -0
  54. mcp_server/tools/images.py +57 -0
  55. mcp_server/tools/networks.py +32 -0
  56. mcp_server/tools/stacks.py +50 -0
  57. mcp_server/tools/system.py +75 -0
  58. mcp_server/tools/volumes.py +44 -0
  59. mcp_server/tools/writes.py +260 -0
@@ -0,0 +1,9 @@
1
+ """container-host-aiops — governed Docker + Portainer container-host ops for AI agents.
2
+
3
+ Standalone and self-contained: the governance harness (audit, token budget,
4
+ undo-token recording, graduated risk tiers, prompt-injection sanitize) is
5
+ bundled under ``container_host_aiops.governance`` — this package has no external
6
+ skill-family dependency. Preview: not yet full-coverage, mock-validated only.
7
+ """
8
+
9
+ __version__ = "0.1.0"
@@ -0,0 +1,9 @@
1
+ """CLI package for container-host-aiops.
2
+
3
+ Re-exports ``app`` so the pyproject entry point
4
+ ``container-host-aiops = "container_host_aiops.cli:app"`` works unchanged.
5
+ """
6
+
7
+ from container_host_aiops.cli._root import app
8
+
9
+ __all__ = ["app"]
@@ -0,0 +1,78 @@
1
+ """Shared helpers for container-host-aiops CLI sub-modules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import functools
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+ from typing import Annotated, Any
9
+
10
+ import typer
11
+ from rich.console import Console
12
+
13
+ console = Console()
14
+
15
+ # ─── Shared Option types ───────────────────────────────────────────────────
16
+
17
+ TargetOption = Annotated[
18
+ str | None, typer.Option("--target", "-t", help="Target name from config")
19
+ ]
20
+ DryRunOption = Annotated[
21
+ bool, typer.Option("--dry-run", help="Print the API call without executing")
22
+ ]
23
+
24
+
25
+ def _cli_error_types() -> tuple[type[BaseException], ...]:
26
+ """Exceptions translated to a one-line teaching error instead of a traceback."""
27
+ from container_host_aiops.connection import ContainerHostApiError
28
+
29
+ return (ContainerHostApiError, KeyError, OSError, ValueError)
30
+
31
+
32
+ def cli_errors(fn: Callable) -> Callable:
33
+ """Translate known exceptions into one red line + exit code 1."""
34
+
35
+ @functools.wraps(fn)
36
+ def wrapper(*args: Any, **kwargs: Any) -> Any:
37
+ try:
38
+ return fn(*args, **kwargs)
39
+ except (typer.Exit, typer.Abort):
40
+ raise
41
+ except _cli_error_types() as e:
42
+ message = str(e)
43
+ if isinstance(e, KeyError):
44
+ message = f"Missing required key or environment variable: {message}"
45
+ console.print(f"[red]Error: {message}[/]")
46
+ raise typer.Exit(1) from e
47
+
48
+ return wrapper
49
+
50
+
51
+ def get_connection(target: str | None, config_path: Path | None = None):
52
+ """Return a (conn, config) tuple for the given target."""
53
+ from container_host_aiops.config import load_config
54
+ from container_host_aiops.connection import ConnectionManager
55
+
56
+ cfg = load_config(config_path)
57
+ mgr = ConnectionManager(cfg)
58
+ return mgr.connect(target), cfg
59
+
60
+
61
+ def dry_run_print(*, operation: str, api_call: str, parameters: dict | None = None) -> None:
62
+ """Print a dry-run preview of the API call that would be made."""
63
+ console.print("\n[bold magenta][DRY-RUN] No changes will be made.[/]")
64
+ console.print(f"[magenta] Operation: {operation}[/]")
65
+ console.print(f"[magenta] API Call: {api_call}[/]")
66
+ for k, v in (parameters or {}).items():
67
+ console.print(f"[magenta] Param: {k} = {v}[/]")
68
+ console.print("[magenta] Run without --dry-run to execute.[/]\n")
69
+
70
+
71
+ def double_confirm(action: str, resource: str) -> None:
72
+ """Require two confirmations for a destructive operation."""
73
+ console.print(f"[bold yellow]⚠️ About to: {action} '{resource}'[/]")
74
+ typer.confirm(f"Confirm 1/2: {action} '{resource}'?", abort=True)
75
+ typer.confirm(
76
+ f"Confirm 2/2: really {action} '{resource}'? This may be irreversible.",
77
+ abort=True,
78
+ )
@@ -0,0 +1,70 @@
1
+ """Top-level Typer app: assembles sub-apps and top-level commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import typer
6
+
7
+ from container_host_aiops.cli._common import cli_errors
8
+ from container_host_aiops.cli.analyze import analyze_app
9
+ from container_host_aiops.cli.container import container_app
10
+ from container_host_aiops.cli.doctor import doctor_cmd
11
+ from container_host_aiops.cli.image import image_app
12
+ from container_host_aiops.cli.init import init_cmd
13
+ from container_host_aiops.cli.manage import manage_app
14
+ from container_host_aiops.cli.network import network_app
15
+ from container_host_aiops.cli.overview import overview_cmd
16
+ from container_host_aiops.cli.secret import secret_app
17
+ from container_host_aiops.cli.stack import stack_app
18
+ from container_host_aiops.cli.system import system_app
19
+ from container_host_aiops.cli.volume import volume_app
20
+
21
+ app = typer.Typer(
22
+ name="container-host-aiops",
23
+ help="Governed AI-ops for Docker + Portainer container hosts: container / "
24
+ "image / volume / network / system reads, flagship analyses, and guarded "
25
+ "lifecycle + prune writes with a built-in governance harness.",
26
+ no_args_is_help=True,
27
+ )
28
+
29
+ app.add_typer(container_app, name="container")
30
+ app.add_typer(image_app, name="image")
31
+ app.add_typer(volume_app, name="volume")
32
+ app.add_typer(network_app, name="network")
33
+ app.add_typer(system_app, name="system")
34
+ app.add_typer(stack_app, name="stack")
35
+ app.add_typer(analyze_app, name="analyze")
36
+ app.add_typer(manage_app, name="manage")
37
+ app.add_typer(secret_app, name="secret")
38
+ app.command("init")(init_cmd)
39
+ app.command("overview")(overview_cmd)
40
+ app.command("doctor")(doctor_cmd)
41
+
42
+
43
+ @app.command("mcp")
44
+ @cli_errors
45
+ def mcp_cmd() -> None:
46
+ """Start the MCP server (stdio transport).
47
+
48
+ Single-command entry point for MCP clients (does not go through uvx/PyPI
49
+ resolution at launch):
50
+ container-host-aiops mcp
51
+ """
52
+ import sys
53
+
54
+ if sys.version_info < (3, 11):
55
+ typer.echo(
56
+ f"ERROR: container-host-aiops requires Python >= 3.11 "
57
+ f"(got {sys.version_info.major}.{sys.version_info.minor}).\n"
58
+ f"Fix: uv python install 3.12 && "
59
+ f"uv tool install --python 3.12 --force container-host-aiops",
60
+ err=True,
61
+ )
62
+ raise typer.Exit(2)
63
+
64
+ from mcp_server.server import main as _mcp_main
65
+
66
+ _mcp_main()
67
+
68
+
69
+ if __name__ == "__main__":
70
+ app()
@@ -0,0 +1,55 @@
1
+ """``container-host-aiops analyze`` — the flagship signature analyses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from container_host_aiops.cli._common import TargetOption, cli_errors, console, get_connection
11
+
12
+ analyze_app = typer.Typer(
13
+ name="analyze",
14
+ help="Flagship analyses: restart-loop RCA, resource pressure, image/volume bloat.",
15
+ no_args_is_help=True,
16
+ )
17
+
18
+
19
+ @analyze_app.command("restart-loop")
20
+ @cli_errors
21
+ def analyze_restart_loop(
22
+ threshold: Annotated[int, typer.Option(help="Restart count = looping")] = 3,
23
+ target: TargetOption = None,
24
+ ) -> None:
25
+ """Find crash-looping containers and map each to a cause + action."""
26
+ from container_host_aiops.ops import analyses as ops
27
+
28
+ conn, _ = get_connection(target)
29
+ rows, logs = ops.pull_restart_data(conn)
30
+ console.print_json(json.dumps(ops.restart_loop_rca(rows, logs, threshold)))
31
+
32
+
33
+ @analyze_app.command("resource-pressure")
34
+ @cli_errors
35
+ def analyze_resource_pressure(
36
+ cpu: Annotated[float, typer.Option(help="CPU%% = over pressure")] = 80.0,
37
+ mem: Annotated[float, typer.Option(help="Memory%% = over pressure")] = 80.0,
38
+ target: TargetOption = None,
39
+ ) -> None:
40
+ """Rank running containers by CPU/memory pressure vs their limits."""
41
+ from container_host_aiops.ops import analyses as ops
42
+
43
+ conn, _ = get_connection(target)
44
+ samples = ops.pull_resource_pressure(conn)
45
+ console.print_json(json.dumps(ops.resource_pressure_analysis(samples, cpu, mem)))
46
+
47
+
48
+ @analyze_app.command("bloat")
49
+ @cli_errors
50
+ def analyze_bloat(target: TargetOption = None) -> None:
51
+ """Dangling images + volumes + build cache → prune candidates."""
52
+ from container_host_aiops.ops import analyses as ops
53
+
54
+ conn, _ = get_connection(target)
55
+ console.print_json(json.dumps(ops.pull_bloat(conn)))
@@ -0,0 +1,85 @@
1
+ """``container-host-aiops container`` — container-scoped reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from container_host_aiops.cli._common import TargetOption, cli_errors, console, get_connection
11
+
12
+ container_app = typer.Typer(
13
+ name="container",
14
+ help="Containers: list, inspect, logs, stats, top, restart summary.",
15
+ no_args_is_help=True,
16
+ )
17
+
18
+ CidArg = Annotated[str, typer.Argument(help="Container id or name")]
19
+
20
+
21
+ @container_app.command("list")
22
+ @cli_errors
23
+ def container_list(
24
+ running: Annotated[bool, typer.Option("--running", help="Only running")] = False,
25
+ target: TargetOption = None,
26
+ ) -> None:
27
+ """List containers (all states by default, or only running)."""
28
+ from container_host_aiops.ops import containers as ops
29
+
30
+ conn, _ = get_connection(target)
31
+ console.print_json(json.dumps(ops.list_containers(conn, all_states=not running)))
32
+
33
+
34
+ @container_app.command("inspect")
35
+ @cli_errors
36
+ def container_inspect(container_id: CidArg, target: TargetOption = None) -> None:
37
+ """Full inspect of one container."""
38
+ from container_host_aiops.ops import containers as ops
39
+
40
+ conn, _ = get_connection(target)
41
+ console.print_json(json.dumps(ops.inspect_container(conn, container_id)))
42
+
43
+
44
+ @container_app.command("logs")
45
+ @cli_errors
46
+ def container_logs(
47
+ container_id: CidArg,
48
+ tail: Annotated[int, typer.Option(help="Lines from the end (1..2000)")] = 100,
49
+ target: TargetOption = None,
50
+ ) -> None:
51
+ """Tail the last N log lines of a container."""
52
+ from container_host_aiops.ops import containers as ops
53
+
54
+ conn, _ = get_connection(target)
55
+ console.print_json(json.dumps(ops.container_logs(conn, container_id, tail)))
56
+
57
+
58
+ @container_app.command("stats")
59
+ @cli_errors
60
+ def container_stats(container_id: CidArg, target: TargetOption = None) -> None:
61
+ """One-shot CPU%/memory% snapshot for a container."""
62
+ from container_host_aiops.ops import containers as ops
63
+
64
+ conn, _ = get_connection(target)
65
+ console.print_json(json.dumps(ops.container_stats(conn, container_id)))
66
+
67
+
68
+ @container_app.command("top")
69
+ @cli_errors
70
+ def container_top(container_id: CidArg, target: TargetOption = None) -> None:
71
+ """Processes running inside a container."""
72
+ from container_host_aiops.ops import containers as ops
73
+
74
+ conn, _ = get_connection(target)
75
+ console.print_json(json.dumps(ops.container_top(conn, container_id)))
76
+
77
+
78
+ @container_app.command("restarts")
79
+ @cli_errors
80
+ def container_restarts(target: TargetOption = None) -> None:
81
+ """Restart-count + exit-code summary across containers."""
82
+ from container_host_aiops.ops import containers as ops
83
+
84
+ conn, _ = get_connection(target)
85
+ console.print_json(json.dumps(ops.restart_summary(conn)))
@@ -0,0 +1,21 @@
1
+ """Doctor top-level command: environment and connectivity check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Annotated
6
+
7
+ import typer
8
+
9
+ from container_host_aiops.cli._common import cli_errors
10
+
11
+
12
+ @cli_errors
13
+ def doctor_cmd(
14
+ skip_auth: Annotated[
15
+ bool, typer.Option("--skip-auth", help="Skip connectivity check (faster)")
16
+ ] = False,
17
+ ) -> None:
18
+ """Check environment, config, secrets, and connectivity."""
19
+ from container_host_aiops.doctor import run_doctor
20
+
21
+ raise typer.Exit(run_doctor(skip_auth=skip_auth))
@@ -0,0 +1,61 @@
1
+ """``container-host-aiops image`` — image-scoped reads."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import Annotated
7
+
8
+ import typer
9
+
10
+ from container_host_aiops.cli._common import TargetOption, cli_errors, console, get_connection
11
+
12
+ image_app = typer.Typer(
13
+ name="image",
14
+ help="Images: list, inspect (with history), dangling, disk usage.",
15
+ no_args_is_help=True,
16
+ )
17
+
18
+ ImageArg = Annotated[str, typer.Argument(help="Image id or name:tag")]
19
+
20
+
21
+ @image_app.command("list")
22
+ @cli_errors
23
+ def image_list(
24
+ all_images: Annotated[bool, typer.Option("--all", help="Include intermediate layers")] = False,
25
+ target: TargetOption = None,
26
+ ) -> None:
27
+ """List images (largest first)."""
28
+ from container_host_aiops.ops import images as ops
29
+
30
+ conn, _ = get_connection(target)
31
+ console.print_json(json.dumps(ops.list_images(conn, all_images)))
32
+
33
+
34
+ @image_app.command("inspect")
35
+ @cli_errors
36
+ def image_inspect(image_id: ImageArg, target: TargetOption = None) -> None:
37
+ """Inspect an image plus its build history."""
38
+ from container_host_aiops.ops import images as ops
39
+
40
+ conn, _ = get_connection(target)
41
+ console.print_json(json.dumps(ops.inspect_image(conn, image_id)))
42
+
43
+
44
+ @image_app.command("dangling")
45
+ @cli_errors
46
+ def image_dangling(target: TargetOption = None) -> None:
47
+ """Untagged (dangling) images + reclaimable bytes."""
48
+ from container_host_aiops.ops import images as ops
49
+
50
+ conn, _ = get_connection(target)
51
+ console.print_json(json.dumps(ops.dangling_images(conn)))
52
+
53
+
54
+ @image_app.command("disk-usage")
55
+ @cli_errors
56
+ def image_disk_usage(target: TargetOption = None) -> None:
57
+ """Image disk usage from system/df."""
58
+ from container_host_aiops.ops import images as ops
59
+
60
+ conn, _ = get_connection(target)
61
+ console.print_json(json.dumps(ops.image_disk_usage(conn)))
@@ -0,0 +1,126 @@
1
+ """``container-host-aiops init`` — a friendly, interactive onboarding wizard.
2
+
3
+ Walks a new user through connecting their first container host: collects the
4
+ non-secret connection details into ``config.yaml`` and — for a Portainer target —
5
+ the API token into the *encrypted* store (never plaintext on disk). A direct
6
+ Docker socket target needs no secret. Designed to be run on a terminal;
7
+ everything it needs is prompted with sensible defaults.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import getpass
13
+
14
+ import typer
15
+ import yaml
16
+
17
+ from container_host_aiops.cli._common import cli_errors, console
18
+ from container_host_aiops.config import CONFIG_DIR, CONFIG_FILE
19
+ from container_host_aiops.platform import (
20
+ DEFAULT_DOCKER_SOCKET,
21
+ DEFAULT_PORTAINER_PORT,
22
+ DOCKER,
23
+ PORTAINER,
24
+ )
25
+ from container_host_aiops.secretstore import SecretStore, resolve_master_password
26
+
27
+
28
+ def _load_existing_targets() -> list[dict]:
29
+ if not CONFIG_FILE.exists():
30
+ return []
31
+ raw = yaml.safe_load(CONFIG_FILE.read_text("utf-8")) or {}
32
+ return list(raw.get("targets", []))
33
+
34
+
35
+ def _write_targets(targets: list[dict]) -> None:
36
+ CONFIG_DIR.mkdir(parents=True, exist_ok=True)
37
+ try:
38
+ CONFIG_DIR.chmod(0o700)
39
+ except OSError:
40
+ pass
41
+ CONFIG_FILE.write_text(yaml.safe_dump({"targets": targets}, sort_keys=False), "utf-8")
42
+
43
+
44
+ @cli_errors
45
+ def init_cmd() -> None:
46
+ """Interactively set up your first Docker or Portainer connection."""
47
+ console.print("[bold cyan]Container Host AIops — setup wizard[/]")
48
+ console.print(
49
+ "This collects Docker or Portainer connection details (saved to "
50
+ "config.yaml). A Portainer target also stores its API token "
51
+ "[bold]encrypted[/] in secrets.enc; a local Docker socket needs no secret.\n"
52
+ )
53
+
54
+ targets = _load_existing_targets()
55
+ existing_names = {t.get("name") for t in targets}
56
+ store: SecretStore | None = None
57
+
58
+ while True:
59
+ console.print("\n[bold]Add a target[/]")
60
+ name = typer.prompt("Target name (e.g. prod1)").strip()
61
+ if name in existing_names:
62
+ if not typer.confirm(f"'{name}' already exists — overwrite?", default=False):
63
+ continue
64
+ targets = [t for t in targets if t.get("name") != name]
65
+
66
+ platform = typer.prompt(
67
+ f"Platform ({DOCKER} / {PORTAINER})", default=DOCKER
68
+ ).strip().lower()
69
+ if platform not in (DOCKER, PORTAINER):
70
+ console.print("[red]Platform must be 'docker' or 'portainer'.[/]")
71
+ continue
72
+
73
+ entry: dict = {"name": name, "platform": platform}
74
+ if platform == DOCKER:
75
+ use_socket = typer.confirm(
76
+ "Connect over a local unix socket? (No = TCP host)", default=True
77
+ )
78
+ if use_socket:
79
+ entry["socket_path"] = typer.prompt(
80
+ "Docker socket path", default=DEFAULT_DOCKER_SOCKET
81
+ ).strip()
82
+ else:
83
+ entry["host"] = typer.prompt("Docker host (IP or FQDN)").strip()
84
+ entry["verify_ssl"] = typer.confirm(
85
+ "Use TLS (https)?", default=False
86
+ )
87
+ else: # portainer
88
+ entry["host"] = typer.prompt("Portainer host (IP or FQDN)").strip()
89
+ entry["port"] = typer.prompt(
90
+ "Portainer HTTPS port", default=DEFAULT_PORTAINER_PORT, type=int
91
+ )
92
+ entry["endpoint_id"] = typer.prompt(
93
+ "Managed Docker endpoint id (for proxied Docker reads)", default="1"
94
+ ).strip()
95
+ entry["verify_ssl"] = typer.confirm(
96
+ "Verify TLS certificate? (No for self-signed)", default=False
97
+ )
98
+ if store is None:
99
+ console.print("\n[bold]Master password[/] (encrypts secrets.enc)")
100
+ console.print(
101
+ "[dim]Set it later via CONTAINER_HOST_AIOPS_MASTER_PASSWORD for "
102
+ "non-interactive/MCP use.[/]"
103
+ )
104
+ password = resolve_master_password(confirm_if_new=True)
105
+ store = SecretStore.unlock(password)
106
+ token = getpass.getpass(f"Portainer API token for '{name}' (hidden): ")
107
+ store = store.set(name, token)
108
+
109
+ targets.append(entry)
110
+ existing_names.add(name)
111
+ _write_targets(targets)
112
+ console.print(f"[green]✓ Saved target '{name}' ({platform}).[/]")
113
+
114
+ if not typer.confirm("\nAdd another target?", default=False):
115
+ break
116
+
117
+ console.print(f"\n[green]✓ Setup complete.[/] Config: {CONFIG_FILE}")
118
+ console.print(
119
+ "[dim]Tip: export CONTAINER_HOST_AIOPS_MASTER_PASSWORD=... in your shell "
120
+ "profile so the MCP server and CLI can unlock Portainer tokens "
121
+ "non-interactively.[/]"
122
+ )
123
+ if typer.confirm("Run a connectivity check now (container-host-aiops doctor)?", default=True):
124
+ from container_host_aiops.doctor import run_doctor
125
+
126
+ raise typer.Exit(run_doctor())