dap-cli 0.3.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.
@@ -0,0 +1,183 @@
1
+ """dap init — bootstrap ./.dap/ + admin user (#302 sub-D4)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import getpass
6
+ import json
7
+ import os
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from rich.console import Console
12
+
13
+ from dap_cli.bootstrap import (
14
+ bootstrap_marker_path,
15
+ ensure_admin_user,
16
+ validate_email,
17
+ validate_password,
18
+ write_bootstrap_marker,
19
+ )
20
+ from dap_cli.paths import (
21
+ DEFAULT_DASHBOARD_PORT,
22
+ DEFAULT_ENGINE_PORT,
23
+ local_config_path,
24
+ local_dap_dir,
25
+ local_db_path,
26
+ )
27
+
28
+ console = Console()
29
+
30
+
31
+ def init_command(
32
+ force: bool = False,
33
+ admin_email: str | None = None,
34
+ admin_password: str | None = None,
35
+ admin_password_stdin: bool = False,
36
+ ) -> None:
37
+ """Initialise ``.dap/`` and create / promote the bootstrap admin.
38
+
39
+ Three input modes for the admin credentials, in order of
40
+ precedence:
41
+
42
+ 1. ``--admin-email`` + ``--admin-password-stdin`` (read password
43
+ from stdin) — automation-friendly, mirrors ``kubectl``.
44
+ 2. ``--admin-email`` + ``--admin-password=...`` — quick, suitable
45
+ for local dev only (the password lands in shell history).
46
+ 3. Interactive prompt — if neither flag is set, prompt for both.
47
+ Empty password → generate a random one and print once.
48
+
49
+ Re-running is idempotent: an existing user with the supplied
50
+ email gets promoted to admin without changing their password.
51
+ """
52
+ dap_dir = local_dap_dir()
53
+ config_path = local_config_path()
54
+
55
+ if dap_dir.exists() and not force:
56
+ console.print(f"[yellow]⚠ .dap/ already exists at {dap_dir}[/yellow]")
57
+ console.print("[dim] Use --force to reinitialize.[/dim]")
58
+ return
59
+
60
+ (dap_dir / "pipelines").mkdir(parents=True, exist_ok=True)
61
+ (dap_dir / "agents").mkdir(parents=True, exist_ok=True)
62
+ (dap_dir / "runs").mkdir(parents=True, exist_ok=True)
63
+
64
+ default_config = {
65
+ "version": 1,
66
+ "engine": {"host": "127.0.0.1", "port": DEFAULT_ENGINE_PORT},
67
+ "dashboard": {"port": DEFAULT_DASHBOARD_PORT},
68
+ "runtimes": {"paths": {}},
69
+ }
70
+ config_path.write_text(json.dumps(default_config, indent=2) + "\n", encoding="utf-8")
71
+
72
+ # Bootstrap the admin user. Errors here are user-visible — we
73
+ # leave the ``.dap/`` skeleton on disk (better than rolling back
74
+ # to "no project at all" and forcing a re-init) but report the
75
+ # admin step as failed so the operator can fix and retry.
76
+ try:
77
+ email, password = _collect_credentials(
78
+ admin_email=admin_email,
79
+ admin_password=admin_password,
80
+ admin_password_stdin=admin_password_stdin,
81
+ )
82
+ except (ValueError, EOFError, KeyboardInterrupt) as exc:
83
+ console.print(f"[red]✗ Admin bootstrap aborted: {exc}[/red]")
84
+ console.print("[dim] Re-run `dap init --force` to retry.[/dim]")
85
+ raise SystemExit(2) from exc
86
+
87
+ # ``DAP_DB_PATH`` (set by the standalone compose to ``/data/state.db``)
88
+ # wins over the in-CWD default so ``dap init`` inside the container
89
+ # writes to the same SQLite the engine reads from. Otherwise the
90
+ # bootstrap row lands in an unrelated ``./.dap/state.db`` file and
91
+ # the operator can't actually log in.
92
+ env_db_path = os.environ.get("DAP_DB_PATH", "").strip()
93
+ db_path = Path(env_db_path) if env_db_path else local_db_path()
94
+ result = ensure_admin_user(
95
+ db_path,
96
+ email=email,
97
+ password=password,
98
+ )
99
+ write_bootstrap_marker(bootstrap_marker_path(dap_dir), result)
100
+
101
+ console.print("[green]✓ Initialized DAP project[/green]")
102
+ console.print(f"[dim] {dap_dir}/[/dim]")
103
+ console.print("[dim] ├─ config.json[/dim]")
104
+ console.print("[dim] ├─ bootstrap.json[/dim] [dim](admin metadata)[/dim]")
105
+ console.print("[dim] ├─ pipelines/[/dim]")
106
+ console.print("[dim] ├─ agents/[/dim]")
107
+ console.print("[dim] └─ runs/[/dim]")
108
+ console.print()
109
+ if result.promoted_existing:
110
+ console.print(
111
+ f"[green]✓ Admin: {result.email}[/green] [dim](existing user — promoted)[/dim]"
112
+ )
113
+ else:
114
+ console.print(f"[green]✓ Admin: {result.email}[/green]")
115
+ if result.generated_password is not None:
116
+ # Print exactly once. We don't store it anywhere — the
117
+ # operator must capture it now or run a reset later.
118
+ console.print()
119
+ console.print("[yellow]⚠ Generated random password — copy it now:[/yellow]")
120
+ console.print(f" [bold]{result.generated_password}[/bold]")
121
+ console.print(
122
+ "[dim] This is the only time it will be shown. "
123
+ "Re-run `dap init --force --admin-email=... --admin-password=...`[/dim]"
124
+ )
125
+ console.print("[dim] to set your own.[/dim]")
126
+ console.print()
127
+ console.print("[cyan]Next: dap start[/cyan]")
128
+
129
+
130
+ def _collect_credentials(
131
+ *,
132
+ admin_email: str | None,
133
+ admin_password: str | None,
134
+ admin_password_stdin: bool,
135
+ ) -> tuple[str, str | None]:
136
+ """Resolve the email + password from flags or interactive prompts.
137
+
138
+ Returns ``(email, password)`` where ``password`` may be ``None``
139
+ when neither a flag nor an interactive entry produced one — in
140
+ that case the bootstrap helper generates a random value.
141
+ """
142
+ # Email — flag wins; otherwise prompt. We never accept email
143
+ # via stdin because mixing stdin email + stdin password would
144
+ # be ambiguous, and emails are short enough to type live.
145
+ if admin_email is None:
146
+ if not sys.stdin.isatty():
147
+ raise ValueError(
148
+ "No --admin-email and stdin is not a TTY — can't prompt. "
149
+ "Pass --admin-email=... explicitly for non-interactive runs."
150
+ )
151
+ admin_email = input("Admin email: ").strip()
152
+ email = validate_email(admin_email)
153
+
154
+ # Password — three paths.
155
+ if admin_password_stdin:
156
+ # Read the WHOLE stdin (operator pipes via ``echo ... | dap init``).
157
+ # Strip trailing newline only — other whitespace is part of the
158
+ # password, the operator picks the format.
159
+ password: str | None = sys.stdin.read().rstrip("\n")
160
+ if not password:
161
+ raise ValueError("--admin-password-stdin set but stdin was empty")
162
+ validate_password(password)
163
+ return email, password
164
+
165
+ if admin_password is not None:
166
+ validate_password(admin_password)
167
+ return email, admin_password
168
+
169
+ if not sys.stdin.isatty():
170
+ # Non-interactive with no password provided → generate one.
171
+ # The bootstrap helper handles the generation; we just signal
172
+ # by returning ``None``.
173
+ return email, None
174
+
175
+ # Interactive prompt with getpass (no echo).
176
+ typed = getpass.getpass("Admin password (leave empty to auto-generate a random one): ")
177
+ if typed == "":
178
+ return email, None
179
+ validate_password(typed)
180
+ confirm = getpass.getpass("Re-enter password: ")
181
+ if confirm != typed:
182
+ raise ValueError("passwords did not match")
183
+ return email, typed
@@ -0,0 +1,134 @@
1
+ """dap project — sub-commands for project-scoped pipeline operations.
2
+
3
+ Current pipeline types: cortex
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from typing import Annotated
9
+
10
+ import typer
11
+
12
+ from dap_cli.commands import cortex as _cortex
13
+ from dap_cli.paths import DEFAULT_ENGINE_PORT
14
+
15
+ project_app = typer.Typer(
16
+ name="project",
17
+ help="Project-scoped pipeline operations",
18
+ no_args_is_help=True,
19
+ add_completion=False,
20
+ )
21
+
22
+ _ENGINE_DEFAULT = f"http://localhost:{DEFAULT_ENGINE_PORT}"
23
+
24
+
25
+ @project_app.command("run")
26
+ def cmd_run(
27
+ pipeline: Annotated[str, typer.Argument(help="Pipeline type (currently: cortex)")],
28
+ issue_url: Annotated[str, typer.Argument(help="GitHub issue URL to process")],
29
+ engine: Annotated[
30
+ str,
31
+ typer.Option("--engine", help="DAP engine base URL", envvar="DAP_ENGINE_URL"),
32
+ ] = _ENGINE_DEFAULT,
33
+ no_interactive: Annotated[
34
+ bool,
35
+ typer.Option("--no-interactive", help="Approve all gates automatically"),
36
+ ] = False,
37
+ watch: Annotated[
38
+ bool,
39
+ typer.Option("--watch", help="Monitor only — do not approve gates"),
40
+ ] = False,
41
+ workspace: Annotated[
42
+ str | None,
43
+ typer.Option("--workspace", help="Local workspace path for the pipeline"),
44
+ ] = None,
45
+ ) -> None:
46
+ """Trigger a pipeline run for a GitHub issue.
47
+
48
+ Example:
49
+
50
+ dap project run cortex https://github.com/Dixter999/cortex-project/issues/332
51
+ """
52
+ if pipeline != "cortex":
53
+ typer.echo(f"Unknown pipeline type: {pipeline!r}. Supported: cortex", err=True)
54
+ raise typer.Exit(1)
55
+ _cortex.cortex_run(
56
+ issue_url=issue_url,
57
+ engine_url=engine,
58
+ no_interactive=no_interactive,
59
+ watch=watch,
60
+ workspace=workspace,
61
+ )
62
+
63
+
64
+ @project_app.command("approve")
65
+ def cmd_approve(
66
+ pipeline: Annotated[str, typer.Argument(help="Pipeline type (currently: cortex)")],
67
+ run_id: Annotated[str, typer.Argument(help="Run ID to approve")],
68
+ engine: Annotated[
69
+ str,
70
+ typer.Option("--engine", help="DAP engine base URL", envvar="DAP_ENGINE_URL"),
71
+ ] = _ENGINE_DEFAULT,
72
+ ) -> None:
73
+ """Approve the current gate for a paused run.
74
+
75
+ Example:
76
+
77
+ dap project approve cortex <run-id>
78
+ """
79
+ if pipeline != "cortex":
80
+ typer.echo(f"Unknown pipeline type: {pipeline!r}. Supported: cortex", err=True)
81
+ raise typer.Exit(1)
82
+ _cortex.cortex_approve(run_id=run_id, engine_url=engine)
83
+
84
+
85
+ @project_app.command("reject")
86
+ def cmd_reject(
87
+ pipeline: Annotated[str, typer.Argument(help="Pipeline type (currently: cortex)")],
88
+ run_id: Annotated[str, typer.Argument(help="Run ID to reject")],
89
+ reason: Annotated[str, typer.Argument(help="Rejection reason")] = "",
90
+ engine: Annotated[
91
+ str,
92
+ typer.Option("--engine", help="DAP engine base URL", envvar="DAP_ENGINE_URL"),
93
+ ] = _ENGINE_DEFAULT,
94
+ ) -> None:
95
+ """Reject (abort) the current gate for a paused run.
96
+
97
+ Example:
98
+
99
+ dap project reject cortex <run-id> "Phase 1 spec incomplete"
100
+ """
101
+ if pipeline != "cortex":
102
+ typer.echo(f"Unknown pipeline type: {pipeline!r}. Supported: cortex", err=True)
103
+ raise typer.Exit(1)
104
+ _cortex.cortex_reject(run_id=run_id, reason=reason, engine_url=engine)
105
+
106
+
107
+ @project_app.command("state")
108
+ def cmd_state(
109
+ pipeline: Annotated[str, typer.Argument(help="Pipeline type (currently: cortex)")],
110
+ run_id: Annotated[str, typer.Argument(help="Run ID to inspect")],
111
+ engine: Annotated[
112
+ str,
113
+ typer.Option("--engine", help="DAP engine base URL", envvar="DAP_ENGINE_URL"),
114
+ ] = _ENGINE_DEFAULT,
115
+ fmt: Annotated[
116
+ str,
117
+ typer.Option(
118
+ "--format",
119
+ help="Output format: table (default) or json",
120
+ click_type=__import__("click").Choice(["table", "json"], case_sensitive=False),
121
+ ),
122
+ ] = "table",
123
+ ) -> None:
124
+ """Show current state of a pipeline run.
125
+
126
+ Example:
127
+
128
+ dap project state cortex <run-id>
129
+ dap project state cortex <run-id> --format json
130
+ """
131
+ if pipeline != "cortex":
132
+ typer.echo(f"Unknown pipeline type: {pipeline!r}. Supported: cortex", err=True)
133
+ raise typer.Exit(1)
134
+ _cortex.cortex_state(run_id=run_id, engine_url=engine, fmt=fmt)
@@ -0,0 +1,124 @@
1
+ """dap start — odpala FastAPI engine in-process + bundled dashboard."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import os
7
+
8
+ import uvicorn
9
+ from rich.console import Console
10
+
11
+ from dap_cli.dashboard import find_bundle, find_node, spawn_dashboard
12
+ from dap_cli.paths import (
13
+ DEFAULT_DASHBOARD_PORT,
14
+ DEFAULT_ENGINE_PORT,
15
+ local_dap_dir,
16
+ local_db_path,
17
+ )
18
+ from dap_cli.process import (
19
+ install_pid_cleanup_handlers,
20
+ is_process_alive,
21
+ read_pid_file,
22
+ remove_pid_file,
23
+ write_pid_file,
24
+ )
25
+
26
+ console = Console()
27
+
28
+
29
+ def start_command(
30
+ port: int = DEFAULT_DASHBOARD_PORT,
31
+ engine_port: int = DEFAULT_ENGINE_PORT,
32
+ headless: bool = False,
33
+ ) -> None:
34
+ if not local_dap_dir().exists():
35
+ console.print("[red]✗ No .dap/ found in current directory.[/red]")
36
+ console.print("[dim] Run `dap init` first.[/dim]")
37
+ raise SystemExit(1)
38
+
39
+ existing = read_pid_file()
40
+ if existing is not None:
41
+ if is_process_alive(existing["pid"]):
42
+ console.print(
43
+ f"[red]✗ DAP already running (PID {existing['pid']}, "
44
+ f"port {existing['port']}).[/red]",
45
+ )
46
+ console.print("[dim] Use `dap stop` first if you want to restart.[/dim]")
47
+ raise SystemExit(1)
48
+ console.print(
49
+ f"[yellow]⚠ Stale PID file "
50
+ f"(process {existing['pid']} not running) — cleaning up.[/yellow]",
51
+ )
52
+ remove_pid_file()
53
+
54
+ # Lazy import — dap --version / --help nie ładują FastAPI/SQLAlchemy
55
+ from dap_engine.app import EngineConfig, create_app # noqa: PLC0415
56
+
57
+ logging.basicConfig(
58
+ level=logging.INFO,
59
+ format="%(asctime)s %(levelname)-7s %(name)s | %(message)s",
60
+ datefmt="%H:%M:%S",
61
+ )
62
+
63
+ config = EngineConfig(
64
+ db_path=str(local_db_path()),
65
+ host="127.0.0.1",
66
+ port=engine_port,
67
+ )
68
+ app = create_app(config)
69
+
70
+ write_pid_file(pid=os.getpid(), port=engine_port)
71
+ install_pid_cleanup_handlers()
72
+
73
+ console.print("[cyan]Starting DAP...[/cyan]")
74
+ console.print(f"[green]✓ engine[/green] http://127.0.0.1:{engine_port}")
75
+
76
+ # Try to spawn the bundled dashboard. ``spawn_dashboard`` returns
77
+ # None when either the bundle (``_dashboard/server.js``) or
78
+ # ``node`` is missing — both are expected in dev installs that
79
+ # haven't run ``scripts/build-dashboard-bundle.sh``.
80
+ dashboard_proc = spawn_dashboard(
81
+ port=port,
82
+ engine_url=f"http://127.0.0.1:{engine_port}",
83
+ )
84
+ if dashboard_proc is not None:
85
+ console.print(f"[green]✓ dashboard[/green] http://127.0.0.1:{port}")
86
+ else:
87
+ # Distinguish the two no-dashboard cases so the operator
88
+ # knows which knob to turn.
89
+ if find_bundle() is None:
90
+ reason = (
91
+ "no bundle in this wheel — run "
92
+ "[bold]scripts/build-dashboard-bundle.sh[/bold] from a checkout"
93
+ )
94
+ elif find_node() is None:
95
+ reason = "Node.js not on PATH — install Node 20+ to enable"
96
+ else:
97
+ reason = "unknown (check logs)"
98
+ console.print(
99
+ f"[yellow]○ dashboard[/yellow] not started [dim]({reason})[/dim]",
100
+ )
101
+
102
+ if not headless and dashboard_proc is not None:
103
+ console.print(f"[dim] Open http://127.0.0.1:{port} in your browser.[/dim]")
104
+ console.print()
105
+ console.print("[dim]Press Ctrl+C to stop.[/dim]")
106
+
107
+ try:
108
+ uvicorn.run(
109
+ app,
110
+ host=config.host,
111
+ port=config.port,
112
+ log_level="info",
113
+ access_log=False,
114
+ )
115
+ finally:
116
+ # Stop the dashboard before clearing our PID file so the
117
+ # operator never sees the dashboard outlive ``dap stop``.
118
+ if dashboard_proc is not None and dashboard_proc.poll() is None:
119
+ dashboard_proc.terminate()
120
+ try:
121
+ dashboard_proc.wait(timeout=5)
122
+ except Exception:
123
+ dashboard_proc.kill()
124
+ remove_pid_file()
@@ -0,0 +1,136 @@
1
+ """dap status — pokazuje stan projektu DAP, runtime info gdy działa."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ import httpx
9
+ from rich.console import Console
10
+ from rich.table import Table
11
+
12
+ from dap_cli.bootstrap import bootstrap_marker_path, read_bootstrap_marker
13
+ from dap_cli.paths import local_dap_dir
14
+ from dap_cli.process import (
15
+ is_process_alive,
16
+ read_pid_file,
17
+ remove_pid_file,
18
+ uptime_from_started_at,
19
+ )
20
+
21
+ console = Console()
22
+
23
+ HTTP_REQUEST_TIMEOUT_SECONDS = 2.0
24
+
25
+
26
+ def _fetch_runtimes(port: int) -> list[dict[str, Any]] | None:
27
+ base = f"http://127.0.0.1:{port}"
28
+ try:
29
+ with httpx.Client(timeout=HTTP_REQUEST_TIMEOUT_SECONDS) as client:
30
+ response = client.get(f"{base}/runtimes")
31
+ response.raise_for_status()
32
+ data = response.json()
33
+ except (httpx.HTTPError, ValueError):
34
+ return None
35
+ if not isinstance(data, list):
36
+ return None
37
+ return data
38
+
39
+
40
+ def _fetch_runtime_health(port: int, runtime_id: str) -> dict[str, Any] | None:
41
+ base = f"http://127.0.0.1:{port}"
42
+ try:
43
+ with httpx.Client(timeout=HTTP_REQUEST_TIMEOUT_SECONDS) as client:
44
+ response = client.get(f"{base}/runtimes/{runtime_id}/health")
45
+ response.raise_for_status()
46
+ data = response.json()
47
+ except (httpx.HTTPError, ValueError):
48
+ return None
49
+ if not isinstance(data, dict):
50
+ return None
51
+ return data
52
+
53
+
54
+ def _print_bootstrap_section(dap_dir: Path) -> None:
55
+ """Print admin-bootstrap state (one line) above the engine status.
56
+
57
+ Reads ``.dap/bootstrap.json`` (written by ``dap init``). Stays
58
+ silent when it's not present *and* the project is otherwise
59
+ initialised — we don't want to nag every ``dap status`` call when
60
+ the operator hasn't bootstrapped yet, so we just show a hint."""
61
+ marker = read_bootstrap_marker(bootstrap_marker_path(dap_dir))
62
+ if marker is None:
63
+ console.print("[yellow]○ admin bootstrap:[/yellow] none — run `dap init` to create one")
64
+ return
65
+ email = marker.get("email", "?")
66
+ created = marker.get("created_at", "?")
67
+ console.print(f"[green]✓ admin bootstrap:[/green] {email} [dim]({created})[/dim]")
68
+
69
+
70
+ def status_command() -> None:
71
+ dap_dir = local_dap_dir()
72
+
73
+ if not dap_dir.exists():
74
+ console.print("[red]✗ Not a DAP project[/red]")
75
+ console.print("[dim] Run `dap init` to initialize.[/dim]")
76
+ return
77
+
78
+ _print_bootstrap_section(dap_dir)
79
+
80
+ pid_info = read_pid_file()
81
+
82
+ if pid_info is None:
83
+ console.print("[green]✓ DAP project[/green]")
84
+ console.print(f"[dim] {dap_dir}[/dim]")
85
+ console.print()
86
+ console.print("[yellow]○ engine: stopped[/yellow]")
87
+ console.print("[dim] Run `dap start` to launch.[/dim]")
88
+ return
89
+
90
+ pid = pid_info["pid"]
91
+ port = pid_info["port"]
92
+ started_at = pid_info["started_at"]
93
+
94
+ if not is_process_alive(pid):
95
+ console.print("[green]✓ DAP project[/green]")
96
+ console.print(f"[dim] {dap_dir}[/dim]")
97
+ console.print()
98
+ console.print(f"[red]✗ engine: stale PID file (process {pid} not running)[/red]")
99
+ remove_pid_file()
100
+ console.print("[dim] Cleaned up. Run `dap start`.[/dim]")
101
+ return
102
+
103
+ uptime = uptime_from_started_at(started_at)
104
+ console.print("[green]✓ DAP project[/green]")
105
+ console.print(f"[dim] {dap_dir}[/dim]")
106
+ console.print()
107
+ console.print(f"[green]● engine: running[/green] PID {pid} port {port} uptime {uptime}")
108
+
109
+ runtimes = _fetch_runtimes(port)
110
+ if runtimes is None:
111
+ console.print(
112
+ "[yellow] ⚠ Engine process running but /runtimes endpoint unreachable.[/yellow]"
113
+ )
114
+ return
115
+
116
+ table = Table(title="Runtime adapters", show_lines=False, header_style="bold")
117
+ table.add_column("ID")
118
+ table.add_column("Display name")
119
+ table.add_column("Kind")
120
+ table.add_column("Available")
121
+
122
+ for entry in runtimes:
123
+ rt_id = str(entry.get("id", "?"))
124
+ health = _fetch_runtime_health(port, rt_id)
125
+ available = (
126
+ "[green]✓[/green]" if health is not None and health.get("available") else "[red]✗[/red]"
127
+ )
128
+ table.add_row(
129
+ rt_id,
130
+ str(entry.get("displayName", "?")),
131
+ str(entry.get("kind", "?")),
132
+ available,
133
+ )
134
+
135
+ console.print()
136
+ console.print(table)
@@ -0,0 +1,35 @@
1
+ """dap stop — wysyła SIGTERM (potem SIGKILL) procesowi z PID file."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+
7
+ from dap_cli.process import is_process_alive, read_pid_file, remove_pid_file, stop_process
8
+
9
+ console = Console()
10
+
11
+
12
+ def stop_command() -> None:
13
+ pid_info = read_pid_file()
14
+ if pid_info is None:
15
+ console.print("[yellow]⚠ No PID file found — DAP is not running.[/yellow]")
16
+ raise SystemExit(1)
17
+
18
+ pid = pid_info["pid"]
19
+ port = pid_info["port"]
20
+
21
+ if not is_process_alive(pid):
22
+ console.print(
23
+ f"[yellow]⚠ Stale PID file (process {pid} not running) — cleaning up.[/yellow]",
24
+ )
25
+ remove_pid_file()
26
+ raise SystemExit(1)
27
+
28
+ console.print(f"[cyan]Stopping DAP (PID {pid}, port {port})...[/cyan]")
29
+ terminated = stop_process(pid)
30
+ remove_pid_file()
31
+
32
+ if terminated:
33
+ console.print("[green]✓ Stopped.[/green]")
34
+ else:
35
+ console.print("[yellow]⚠ Process already exited before SIGTERM.[/yellow]")