machinaos 0.0.74 → 0.0.76

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.
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "react-flow-client",
3
3
  "private": true,
4
- "version": "0.0.74",
4
+ "version": "0.0.76",
5
5
  "type": "module",
6
6
  "scripts": {
7
7
  "start": "vite --host 0.0.0.0",
@@ -0,0 +1,47 @@
1
+ # machina
2
+
3
+ Project supervisor CLI for the MachinaOS stack. Replaces `scripts/*.js`
4
+ gradually; backwards-compatible with `pnpm run start`/`dev`/`stop`/etc.
5
+
6
+ ## Install
7
+
8
+ Done automatically in `scripts/postinstall.js` after `pnpm install`. To
9
+ install manually:
10
+
11
+ ```sh
12
+ python -m pip install -e ./machina
13
+ ```
14
+
15
+ ## Use
16
+
17
+ ```sh
18
+ python -m machina --help # list commands
19
+ python -m machina stop # kill ports + orphans + temporal
20
+ ```
21
+
22
+ Or via the npm scripts (which now invoke the same Python CLI):
23
+
24
+ ```sh
25
+ pnpm run stop
26
+ ```
27
+
28
+ ## Cross-platform notes
29
+
30
+ - **Windows**: `python` resolves correctly out of the box.
31
+ - **macOS / Linux**: requires `python` to point to Python 3.11+. Most
32
+ modern distros provide this via `python-is-python3` or a symlink. If
33
+ your distro only ships `python3`, run `python3 -m machina <cmd>` or
34
+ add an alias.
35
+
36
+ ## Architecture
37
+
38
+ Built on battle-tested primitives — minimal custom code:
39
+
40
+ | Layer | Library |
41
+ |---|---|
42
+ | CLI | `typer` |
43
+ | Subprocess | `anyio.open_process` |
44
+ | Tree-kill | `psutil` + `pywin32` (Job Objects on Windows) |
45
+ | Restart backoff | stdlib (deque + monotonic time + jittered exponential, ~20 LOC) |
46
+ | Output | `rich.Console` |
47
+ | Env | `python-dotenv` |
@@ -0,0 +1,3 @@
1
+ """MachinaOS project supervisor CLI."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,35 @@
1
+ """Entry point for ``python -m machina``.
2
+
3
+ pnpm scripts call ``python -m machina ...`` from the system Python,
4
+ which only has ``uv`` -- not typer / rich / anyio / psutil. On first
5
+ run we detect the missing imports, pip-install the declared runtime
6
+ deps, and retry, so the user never has to ``pip install -e .`` by hand.
7
+ """
8
+
9
+ import subprocess
10
+ import sys
11
+
12
+ _RUNTIME_DEPS = (
13
+ "typer>=0.12",
14
+ "rich>=13.0",
15
+ "anyio>=4.0",
16
+ "psutil>=6.0",
17
+ )
18
+
19
+
20
+ def _bootstrap_deps() -> None:
21
+ print("machina: installing runtime dependencies (first run)...", file=sys.stderr)
22
+ subprocess.check_call(
23
+ [sys.executable, "-m", "pip", "install", "--quiet", *_RUNTIME_DEPS]
24
+ )
25
+
26
+
27
+ try:
28
+ from machina.cli import app
29
+ except ImportError:
30
+ _bootstrap_deps()
31
+ from machina.cli import app
32
+
33
+
34
+ if __name__ == "__main__":
35
+ app()
@@ -0,0 +1,44 @@
1
+ """Build-environment helpers shared by ``start`` / ``dev`` / ``daemon``.
2
+
3
+ Three commands had near-duplicate copies of "find the venv python" and
4
+ "the build artifacts must exist or refuse to start". Lifted here so a
5
+ new launcher gets both for free.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+ import typer
13
+
14
+ from machina.colors import console
15
+
16
+
17
+ def venv_python(root: Path) -> Path | None:
18
+ """Return the project's venv python path, or ``None`` if absent.
19
+
20
+ Tries the Windows layout first (``Scripts/python.exe``) then POSIX
21
+ (``bin/python``); the first existing path wins.
22
+ """
23
+ for candidate in (
24
+ root / "server" / ".venv" / "Scripts" / "python.exe",
25
+ root / "server" / ".venv" / "bin" / "python",
26
+ ):
27
+ if candidate.exists():
28
+ return candidate
29
+ return None
30
+
31
+
32
+ def validate_build(root: Path, *, require_client_dist: bool = False) -> None:
33
+ """Refuse to launch if ``machina build`` hasn't been run.
34
+
35
+ Raises ``typer.Exit(1)`` with a remediation hint. ``dev`` allows a
36
+ missing ``client/dist`` (Vite serves the source); ``start`` opts in
37
+ via ``require_client_dist=True``.
38
+ """
39
+ if not (root / "node_modules").exists() or not (root / "server" / ".venv").exists():
40
+ console.print('[red]Error: Project not built. Run "machina build" first.[/]')
41
+ raise typer.Exit(code=1)
42
+ if require_client_dist and not (root / "client" / "dist" / "index.html").exists():
43
+ console.print('[red]Error: Client not built. Run "machina build" first.[/]')
44
+ raise typer.Exit(code=1)
package/machina/cli.py ADDED
@@ -0,0 +1,55 @@
1
+ """Typer CLI for ``machina``.
2
+
3
+ Each subcommand lives under ``machina.commands``; this module just
4
+ mounts them and exposes ``app`` as the entry point.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import typer
10
+
11
+ from machina.commands.build import build_command
12
+ from machina.commands.clean import clean_command
13
+ from machina.commands.daemon import app as daemon_app
14
+ from machina.commands.dev import dev_command
15
+ from machina.commands.docs import app as docs_app
16
+ from machina.commands.start import start_command
17
+ from machina.commands.stop import stop_command
18
+ from machina.commands.version import app as version_app
19
+
20
+
21
+ app = typer.Typer(
22
+ name="machina",
23
+ help="MachinaOS project supervisor CLI.",
24
+ no_args_is_help=True,
25
+ add_completion=False,
26
+ )
27
+
28
+
29
+ @app.callback()
30
+ def _root() -> None:
31
+ """Marks the Typer app as a multi-command group."""
32
+
33
+
34
+ app.command("start", help="Start all services in production mode (static client + uvicorn + temporal).")(
35
+ start_command
36
+ )
37
+ app.command("dev", help="Start all services in dev mode (Vite HMR + uvicorn + temporal).")(
38
+ dev_command
39
+ )
40
+ app.command("stop", help="Stop all MachinaOS services and free configured ports.")(
41
+ stop_command
42
+ )
43
+ app.command("clean", help="Stop services then remove build artefacts and venvs.")(
44
+ clean_command
45
+ )
46
+ app.command("build", help="Install toolchain + build client + sync Python + verify deps.")(
47
+ build_command
48
+ )
49
+ app.add_typer(version_app, name="version")
50
+ app.add_typer(docs_app, name="docs")
51
+ app.add_typer(daemon_app, name="daemon")
52
+
53
+
54
+ if __name__ == "__main__":
55
+ app()
@@ -0,0 +1,47 @@
1
+ """Rich console + per-service color rotation (Honcho-style).
2
+
3
+ Latency-analysis hook: ``emit`` routes through ``Console.log`` instead of
4
+ ``Console.print``, which prepends a built-in timestamp column. The console
5
+ is configured with ``log_time_format=[%H:%M:%S.%f]`` for ms precision and
6
+ ``log_path=False`` to drop the file:line caller info that rich adds by
7
+ default. ``console.print`` callers (banner / step headers) remain timestamp-
8
+ less so the launch banner stays readable.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from itertools import cycle
14
+ from rich.console import Console
15
+
16
+
17
+ # Single shared console — single-writer aggregator avoids interleaving
18
+ # concurrent stream output (VS Code's pty host pattern). ``log_*`` settings
19
+ # only affect ``console.log`` calls; ``console.print`` is unaffected.
20
+ console = Console(log_time_format="[%H:%M:%S.%f]", log_path=False)
21
+
22
+
23
+ # Honcho's rotation, lifted: skip very-dark (black) and very-bright (white)
24
+ # so prefixes remain readable on both light and dark terminals.
25
+ _PALETTE = [
26
+ "cyan", "green", "yellow", "blue", "magenta",
27
+ "bright_cyan", "bright_green", "bright_yellow", "bright_blue", "bright_magenta",
28
+ ]
29
+ _color_cycle = cycle(_PALETTE)
30
+
31
+
32
+ def next_color() -> str:
33
+ return next(_color_cycle)
34
+
35
+
36
+ def emit(name: str, color: str, line: str, *, stream: str = "stdout") -> None:
37
+ """Print one line tagged with the service name + color, timestamped.
38
+
39
+ Renders as ``[14:23:45.123] cyan | <message>``. The timestamp
40
+ column is added by ``console.log``; prefix width stays uniform so
41
+ side-by-side latency comparison across services lines up visually.
42
+ """
43
+ width = 12
44
+ prefix = f"[{color}]{name:<{width}}[/{color}]"
45
+ style = "" if stream == "stdout" else "[dim]"
46
+ suffix = "" if stream == "stdout" else "[/dim]"
47
+ console.log(f"{prefix}{style} | {line}{suffix}", highlight=False)
@@ -0,0 +1 @@
1
+ """``machina`` subcommand modules. Imported by ``machina.cli``."""
@@ -0,0 +1,170 @@
1
+ """``machina build`` -- replaces ``scripts/build.js``.
2
+
3
+ Checks toolchain (node, npm, python, uv, temporal-server), then runs
4
+ the 4-step build: ``.env`` bootstrap -> ``pnpm install`` -> client
5
+ build -> ``uv sync`` -> verify edgymeow binary.
6
+
7
+ The ``MACHINAOS_BUILDING`` env var is set so ``scripts/postinstall.js``
8
+ skips its own ``install.js`` invocation when build is the orchestrator.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ import re
15
+ import shutil
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ import typer
20
+
21
+ from machina.run import capture, run
22
+ from machina.colors import console
23
+ from machina.platform_ import project_root
24
+
25
+
26
+ # ---------------------------------------------------------------- helpers
27
+
28
+ def _which_python() -> str | None:
29
+ """Prefer ``python3`` so we don't pick up Python 2.x on POSIX distros."""
30
+ for cmd in ("python3", "python"):
31
+ if shutil.which(cmd):
32
+ return cmd
33
+ return None
34
+
35
+
36
+ def _check_python(cmd: str) -> bool:
37
+ out = capture([cmd, "--version"])
38
+ if not out:
39
+ return False
40
+ match = re.search(r"Python (\d+)\.(\d+)", out)
41
+ if not match:
42
+ return False
43
+ major, minor = int(match.group(1)), int(match.group(2))
44
+ if major >= 3 and minor >= 12:
45
+ console.print(f" {out}")
46
+ return True
47
+ console.print(f" {out} [red](too old, need 3.12+)[/]")
48
+ return False
49
+
50
+
51
+ def _ensure_pip(python_cmd: str) -> None:
52
+ if not capture([python_cmd, "-m", "pip", "--version"]):
53
+ console.print(" Installing pip via ensurepip...")
54
+ run([python_cmd, "-m", "ensurepip", "--upgrade"])
55
+
56
+
57
+ def _ensure_uv(python_cmd: str) -> str:
58
+ """Install ``uv`` via pip if missing; return the resolved version string."""
59
+ version = capture(["uv", "--version"])
60
+ if version:
61
+ console.print(f" uv: {version}")
62
+ return version
63
+ _ensure_pip(python_cmd)
64
+ console.print(" Installing uv via pip...")
65
+ run([python_cmd, "-m", "pip", "install", "uv"])
66
+ version = capture(["uv", "--version"])
67
+ if not version:
68
+ console.print("[red]Error: failed to install uv. See https://docs.astral.sh/uv/[/]")
69
+ raise typer.Exit(code=1)
70
+ console.print(f" uv: {version}")
71
+ return version
72
+
73
+
74
+ def _ensure_temporal() -> None:
75
+ """Ensure the ``temporal`` CLI (from npm package ``temporal-server``) is on PATH."""
76
+ version = capture(["temporal", "--version"])
77
+ if version:
78
+ console.print(f" temporal: {version}")
79
+ return
80
+ console.print(" temporal: not found, installing globally...")
81
+ rc = run(["npm", "install", "-g", "temporal-server"], check=False)
82
+ if rc != 0:
83
+ console.print(
84
+ " [yellow]Warning: temporal install failed. "
85
+ "Distributed execution unavailable.[/]"
86
+ )
87
+ return
88
+ version = capture(["temporal", "--version"])
89
+ if version:
90
+ console.print(f" temporal: {version}")
91
+
92
+
93
+ # ---------------------------------------------------------------- build
94
+
95
+ def build_command() -> None:
96
+ root = project_root()
97
+
98
+ # Prevent the postinstall orchestrator from re-running install.js when
99
+ # we're orchestrating ourselves (matches the existing JS contract).
100
+ os.environ["MACHINAOS_BUILDING"] = "true"
101
+ os.environ.setdefault("PYTHONUTF8", "1")
102
+
103
+ is_postinstall = os.environ.get("npm_lifecycle_event") == "postinstall"
104
+ is_ci = os.environ.get("CI") == "true" or os.environ.get("GITHUB_ACTIONS") == "true"
105
+ if is_ci and is_postinstall:
106
+ console.print("CI environment detected, skipping postinstall build.")
107
+ return
108
+
109
+ # ---- toolchain ---------------------------------------------------
110
+ console.print("[bold]Checking dependencies...[/]\n")
111
+ node_version = capture(["node", "--version"])
112
+ console.print(f" Node.js: {node_version or '[red]not found[/]'}")
113
+ if not node_version:
114
+ console.print("[red]Error: Node.js is required.[/]")
115
+ raise typer.Exit(code=1)
116
+
117
+ npm_version = capture(["npm", "--version"])
118
+ console.print(f" npm: {npm_version or '[red]not found[/]'}")
119
+
120
+ python_cmd = _which_python()
121
+ if not python_cmd or not _check_python(python_cmd):
122
+ console.print(
123
+ "[red]Error: Python 3.12+ is required.[/] "
124
+ "Install from https://python.org/downloads/"
125
+ )
126
+ raise typer.Exit(code=1)
127
+
128
+ _ensure_uv(python_cmd)
129
+ _ensure_temporal()
130
+
131
+ console.print("\n[green]All dependencies ready.[/]\n")
132
+
133
+ # ---- build steps -------------------------------------------------
134
+ server_dir = root / "server"
135
+ env_path = root / ".env"
136
+ template_path = root / ".env.template"
137
+
138
+ # Step markers go through ``console.log`` so each [N/5] line is
139
+ # timestamped — diff between consecutive timestamps is the wall-clock
140
+ # cost of that step, no manual instrumentation needed.
141
+ if not env_path.exists() and template_path.exists():
142
+ shutil.copy2(template_path, env_path)
143
+ console.log("[0/5] Created .env from template")
144
+
145
+ if not is_postinstall:
146
+ console.log("[1/5] Installing dependencies...")
147
+ run(["pnpm", "install"], cwd=root)
148
+ else:
149
+ console.log("[1/5] Dependencies already installed by package manager")
150
+
151
+ console.log("[2/5] Building client...")
152
+ run(["pnpm", "--filter", "react-flow-client", "run", "build"], cwd=root)
153
+
154
+ console.log("[3/4] Installing Python dependencies...")
155
+ if not (server_dir / ".venv").exists():
156
+ run(["uv", "venv"], cwd=server_dir)
157
+ run(["uv", "sync"], cwd=server_dir)
158
+
159
+ console.log("[4/4] Verifying edgymeow binary...")
160
+ bin_name = "edgymeow-server.exe" if sys.platform == "win32" else "edgymeow-server"
161
+ edgymeow_bin = root / "node_modules" / "edgymeow" / "bin" / bin_name
162
+ if edgymeow_bin.exists():
163
+ console.print(f" Binary present: {edgymeow_bin}")
164
+ else:
165
+ console.print(
166
+ " [yellow]Warning: edgymeow binary not found. "
167
+ "Set WHATSAPP_RUNTIME_ENABLED=false to disable.[/]"
168
+ )
169
+
170
+ console.log("[green]Build complete.[/]")
@@ -0,0 +1,80 @@
1
+ """``machina clean`` -- replaces ``scripts/clean.js``.
2
+
3
+ Stops every process listening on the configured ports + orphaned project
4
+ processes, waits for file locks to release, then removes build artefacts
5
+ and venvs.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import time
12
+ from pathlib import Path
13
+
14
+ from machina.colors import console
15
+ from machina.config import load_config
16
+ from machina.platform_ import project_root
17
+ from machina.ports import kill_orphaned_machina_processes, kill_port
18
+
19
+
20
+ # Removed each run -- order doesn't matter, ``shutil.rmtree`` is recursive.
21
+ _TARGETS = [
22
+ "node_modules",
23
+ "client/node_modules",
24
+ "client/dist",
25
+ "client/.vite",
26
+ "server/data", # workflow.db, credentials.db, workspaces/
27
+ "server/.venv",
28
+ ".venv", # stale root venv (should not exist)
29
+ ]
30
+
31
+
32
+ def _rmtree_with_retry(path: Path, *, attempts: int = 3, delay: float = 0.1) -> bool:
33
+ """``shutil.rmtree`` with Windows-friendly retry on file-lock errors."""
34
+ for attempt in range(attempts):
35
+ try:
36
+ shutil.rmtree(path, ignore_errors=False)
37
+ return True
38
+ except OSError as exc:
39
+ if attempt == attempts - 1:
40
+ console.print(f" [yellow]Warning: Could not remove {path.name}: {exc}[/]")
41
+ return False
42
+ time.sleep(delay)
43
+ return False
44
+
45
+
46
+ def clean_command() -> None:
47
+ cfg = load_config()
48
+ root = project_root()
49
+
50
+ console.print("[bold]Cleaning MachinaOS...[/]\n")
51
+
52
+ # Step 1: kill anything on configured ports
53
+ console.print("Stopping running processes...")
54
+ killed_ports = 0
55
+ for port in cfg.all_ports:
56
+ result = kill_port(port)
57
+ if result.killed_pids:
58
+ console.print(f" Port {port}: Killed {len(result.killed_pids)} process(es)")
59
+ killed_ports += len(result.killed_pids)
60
+
61
+ # Step 2: kill orphaned project processes (may hold .venv file locks)
62
+ orphaned = kill_orphaned_machina_processes(str(root), exclude_substring="machina")
63
+ if orphaned:
64
+ console.print(f" Orphaned: Killed {len(orphaned)} process(es)")
65
+
66
+ if killed_ports or orphaned:
67
+ console.print(" Waiting for processes to release file locks...")
68
+ time.sleep(1.0)
69
+ else:
70
+ console.print(" No running processes found.")
71
+
72
+ # Step 3: remove directories
73
+ console.print("\nRemoving directories...")
74
+ for target in _TARGETS:
75
+ path = root / target
76
+ if path.exists():
77
+ console.print(f" Removing: {target}")
78
+ _rmtree_with_retry(path)
79
+
80
+ console.print("\n[green]Done.[/]")
@@ -0,0 +1,150 @@
1
+ """``machina daemon`` -- run the backend as a detached process.
2
+
3
+ Pure-Python, all-platforms. No NSSM / systemd / launchd integration.
4
+ Spawns ``uvicorn main:app`` in a new session (POSIX) or detached
5
+ process group (Windows), writes the PID under ``~/.machina/`` so the
6
+ other verbs can find it, and uses ``psutil`` for tree-kill on stop.
7
+
8
+ For boot-time auto-start, configure your OS service manager
9
+ separately (`systemctl`, `launchctl`, Task Scheduler) -- this CLI does
10
+ not register itself with the system.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import signal
17
+ import subprocess
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ import psutil
22
+ import typer
23
+
24
+ from machina.buildenv import venv_python
25
+ from machina.colors import console
26
+ from machina.platform_ import IS_WINDOWS, project_root
27
+
28
+ app = typer.Typer(
29
+ name="daemon",
30
+ help="Run the MachinaOs backend as a detached process.",
31
+ no_args_is_help=True,
32
+ add_completion=False,
33
+ )
34
+
35
+
36
+ # ---------------------------------------------------------------- helpers
37
+
38
+ _PID_DIR = Path.home() / ".machina"
39
+ _PID_FILE = _PID_DIR / "machina-backend.pid"
40
+
41
+
42
+ def _detached_kwargs() -> dict:
43
+ """Cross-platform "spawn detached, survive parent exit" kwargs."""
44
+ if IS_WINDOWS:
45
+ # CREATE_NEW_PROCESS_GROUP lets us send CTRL_BREAK_EVENT later;
46
+ # DETACHED_PROCESS releases the console handle.
47
+ return {
48
+ "creationflags": (
49
+ subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP
50
+ ),
51
+ }
52
+ # POSIX: setsid puts the child in its own process group / session,
53
+ # so killing the parent (this CLI) doesn't take the daemon down.
54
+ return {"start_new_session": True}
55
+
56
+
57
+ def _read_pid() -> int | None:
58
+ if not _PID_FILE.exists():
59
+ return None
60
+ try:
61
+ pid = int(_PID_FILE.read_text().strip())
62
+ except (ValueError, OSError):
63
+ return None
64
+ return pid if psutil.pid_exists(pid) else None
65
+
66
+
67
+ def _kill_tree(pid: int) -> None:
68
+ try:
69
+ proc = psutil.Process(pid)
70
+ except psutil.NoSuchProcess:
71
+ return
72
+ for child in proc.children(recursive=True):
73
+ try:
74
+ child.kill()
75
+ except psutil.NoSuchProcess:
76
+ pass
77
+ try:
78
+ proc.terminate()
79
+ proc.wait(timeout=5)
80
+ except psutil.NoSuchProcess:
81
+ return
82
+ except psutil.TimeoutExpired:
83
+ try:
84
+ proc.kill()
85
+ except psutil.NoSuchProcess:
86
+ pass
87
+
88
+
89
+ # ---------------------------------------------------------------- verbs
90
+
91
+
92
+ @app.command("start")
93
+ def start_command() -> None:
94
+ """Start the backend in the background; write PID to ~/.machina/."""
95
+ if (existing := _read_pid()) is not None:
96
+ console.print(f"[yellow]Already running (pid={existing}).[/]")
97
+ return
98
+
99
+ root = project_root()
100
+ server = root / "server"
101
+ py = venv_python(root)
102
+ if py is None:
103
+ console.print(f'[red]Python venv not found at {py}.[/] Run "machina build" first.')
104
+ raise typer.Exit(code=1)
105
+
106
+ _PID_DIR.mkdir(parents=True, exist_ok=True)
107
+ log_file = _PID_DIR / "backend.log"
108
+ log = log_file.open("ab")
109
+
110
+ proc = subprocess.Popen(
111
+ [str(py), "-m", "uvicorn", "main:app",
112
+ "--host", "0.0.0.0", "--port", "3010", "--log-level", "warning"],
113
+ cwd=str(server),
114
+ stdin=subprocess.DEVNULL,
115
+ stdout=log,
116
+ stderr=log,
117
+ **_detached_kwargs(),
118
+ )
119
+ _PID_FILE.write_text(str(proc.pid))
120
+ console.print(f"[green]Started pid={proc.pid}[/] (logs: {log_file})")
121
+
122
+
123
+ @app.command("stop")
124
+ def stop_command() -> None:
125
+ """Stop the backend if running; clear PID file."""
126
+ pid = _read_pid()
127
+ if pid is None:
128
+ console.print("Not running.")
129
+ _PID_FILE.unlink(missing_ok=True)
130
+ return
131
+ _kill_tree(pid)
132
+ _PID_FILE.unlink(missing_ok=True)
133
+ console.print(f"[green]Stopped pid={pid}[/]")
134
+
135
+
136
+ @app.command("status")
137
+ def status_command() -> None:
138
+ """Report whether the backend is running."""
139
+ pid = _read_pid()
140
+ if pid is None:
141
+ console.print("Not running.")
142
+ raise typer.Exit(code=1)
143
+ console.print(f"[green]Running pid={pid}[/]")
144
+
145
+
146
+ @app.command("restart")
147
+ def restart_command() -> None:
148
+ """Stop then start; convenience wrapper."""
149
+ stop_command()
150
+ start_command()