toq-plugins-common 0.1.0.dev1__tar.gz

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,8 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ *.whl
7
+ .venv/
8
+ .env
@@ -0,0 +1,7 @@
1
+ Metadata-Version: 2.4
2
+ Name: toq-plugins-common
3
+ Version: 0.1.0.dev1
4
+ Summary: Shared daemon management for toq framework plugins
5
+ License-Expression: Apache-2.0
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: httpx>=0.28
@@ -0,0 +1,14 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "toq-plugins-common"
7
+ version = "0.1.0.dev1"
8
+ description = "Shared daemon management for toq framework plugins"
9
+ requires-python = ">=3.10"
10
+ license = "Apache-2.0"
11
+ dependencies = ["httpx>=0.28"]
12
+
13
+ [tool.hatch.build.targets.wheel]
14
+ packages = ["toq_plugins_common"]
@@ -0,0 +1,134 @@
1
+ """Tests for toq_plugins_common.binary module."""
2
+
3
+ import platform
4
+ from pathlib import Path
5
+ from unittest.mock import patch
6
+
7
+ import pytest
8
+
9
+ from toq_plugins_common import binary
10
+
11
+
12
+ def test_detect_platform_darwin_arm():
13
+ with patch.object(platform, "system", return_value="Darwin"), \
14
+ patch.object(platform, "machine", return_value="arm64"):
15
+ assert binary.detect_platform() == "darwin-aarch64"
16
+
17
+
18
+ def test_detect_platform_linux_x86():
19
+ with patch.object(platform, "system", return_value="Linux"), \
20
+ patch.object(platform, "machine", return_value="x86_64"):
21
+ assert binary.detect_platform() == "linux-x86_64"
22
+
23
+
24
+ def test_detect_platform_darwin_x86():
25
+ with patch.object(platform, "system", return_value="Darwin"), \
26
+ patch.object(platform, "machine", return_value="x86_64"):
27
+ assert binary.detect_platform() == "darwin-x86_64"
28
+
29
+
30
+ def test_detect_platform_linux_aarch64():
31
+ with patch.object(platform, "system", return_value="Linux"), \
32
+ patch.object(platform, "machine", return_value="aarch64"):
33
+ assert binary.detect_platform() == "linux-aarch64"
34
+
35
+
36
+ def test_detect_platform_unsupported_os():
37
+ with patch.object(platform, "system", return_value="Windows"), \
38
+ patch.object(platform, "machine", return_value="x86_64"):
39
+ with pytest.raises(binary.UnsupportedPlatformError, match="Unsupported OS"):
40
+ binary.detect_platform()
41
+
42
+
43
+ def test_detect_platform_unsupported_arch():
44
+ with patch.object(platform, "system", return_value="Linux"), \
45
+ patch.object(platform, "machine", return_value="mips"):
46
+ with pytest.raises(binary.UnsupportedPlatformError, match="Unsupported architecture"):
47
+ binary.detect_platform()
48
+
49
+
50
+ def test_binary_path():
51
+ path = binary.binary_path()
52
+ assert path == Path.home() / ".toq" / "bin" / "toq"
53
+
54
+
55
+ def test_bundled_binary_path_raises_without_set():
56
+ binary._bundled_bin_dir = None
57
+ with pytest.raises(RuntimeError, match="No bundled binary directory"):
58
+ binary.bundled_binary_path()
59
+
60
+
61
+ def test_set_bundled_bin_dir():
62
+ test_path = Path("/tmp/test-bin")
63
+ binary.set_bundled_bin_dir(test_path)
64
+ assert binary._bundled_bin_dir == test_path
65
+ binary._bundled_bin_dir = None # cleanup
66
+
67
+
68
+ def test_bundled_binary_path_returns_correct_after_set():
69
+ test_path = Path("/tmp/test-bin")
70
+ binary.set_bundled_bin_dir(test_path)
71
+ with patch.object(binary, "detect_platform", return_value="darwin-aarch64"):
72
+ result = binary.bundled_binary_path()
73
+ assert result == test_path / "darwin-aarch64" / "toq"
74
+ binary._bundled_bin_dir = None # cleanup
75
+
76
+
77
+ def test_ensure_extracted_copies_when_target_missing(tmp_path):
78
+ bundled_dir = tmp_path / "bundled" / "darwin-aarch64"
79
+ bundled_dir.mkdir(parents=True)
80
+ bundled_bin = bundled_dir / "toq"
81
+ bundled_bin.write_text("#!/bin/sh\necho toq 0.1.0")
82
+ bundled_bin.chmod(0o755)
83
+
84
+ target_dir = tmp_path / "bin"
85
+ target = target_dir / "toq"
86
+
87
+ binary.set_bundled_bin_dir(tmp_path / "bundled")
88
+
89
+ with patch.object(binary, "BIN_DIR", target_dir), \
90
+ patch.object(binary, "detect_platform", return_value="darwin-aarch64"):
91
+ result = binary.ensure_extracted()
92
+ assert result == target
93
+ assert target.exists()
94
+
95
+ binary._bundled_bin_dir = None
96
+
97
+
98
+ def test_ensure_extracted_raises_if_bundled_missing(tmp_path):
99
+ bundled_dir = tmp_path / "bundled"
100
+ bundled_dir.mkdir()
101
+ binary.set_bundled_bin_dir(bundled_dir)
102
+
103
+ with patch.object(binary, "BIN_DIR", tmp_path / "bin"), \
104
+ patch.object(binary, "detect_platform", return_value="darwin-aarch64"):
105
+ with pytest.raises(FileNotFoundError, match="No bundled toq binary"):
106
+ binary.ensure_extracted()
107
+
108
+ binary._bundled_bin_dir = None
109
+
110
+
111
+ def test_ensure_extracted_skips_if_versions_match(tmp_path):
112
+ import shutil
113
+
114
+ bundled_dir = tmp_path / "bundled" / "darwin-aarch64"
115
+ bundled_dir.mkdir(parents=True)
116
+ bundled_bin = bundled_dir / "toq"
117
+ bundled_bin.write_text("fake")
118
+ bundled_bin.chmod(0o755)
119
+
120
+ target_dir = tmp_path / "bin"
121
+ target_dir.mkdir()
122
+ target = target_dir / "toq"
123
+ target.write_text("fake")
124
+ target.chmod(0o755)
125
+
126
+ binary.set_bundled_bin_dir(tmp_path / "bundled")
127
+
128
+ with patch.object(binary, "BIN_DIR", target_dir), \
129
+ patch.object(binary, "detect_platform", return_value="darwin-aarch64"), \
130
+ patch.object(binary, "_get_version", return_value="toq 0.1.0"):
131
+ result = binary.ensure_extracted()
132
+ assert result == target
133
+
134
+ binary._bundled_bin_dir = None
@@ -0,0 +1,105 @@
1
+ """Tests for toq_plugins_common.daemon module."""
2
+
3
+ import subprocess
4
+ from unittest.mock import patch, MagicMock
5
+
6
+ import pytest
7
+
8
+ from toq_plugins_common import daemon
9
+
10
+
11
+ def test_is_running_true():
12
+ with patch("httpx.get") as mock_get:
13
+ mock_get.return_value = MagicMock(status_code=200)
14
+ assert daemon.is_running(9009) is True
15
+
16
+
17
+ def test_is_running_false_on_connect_error():
18
+ import httpx
19
+ with patch("httpx.get", side_effect=httpx.ConnectError("refused")):
20
+ assert daemon.is_running(9009) is False
21
+
22
+
23
+ def test_is_running_false_on_timeout():
24
+ import httpx
25
+ with patch("httpx.get", side_effect=httpx.TimeoutException("timeout")):
26
+ assert daemon.is_running(9009) is False
27
+
28
+
29
+ def test_stop_noop_when_no_managed_process():
30
+ daemon._managed_process = None
31
+ daemon.stop() # should not raise
32
+
33
+
34
+ def test_stop_sends_shutdown_and_waits():
35
+ mock_proc = MagicMock()
36
+ mock_proc.wait.return_value = 0
37
+ daemon._managed_process = mock_proc
38
+ daemon._log_file = MagicMock()
39
+
40
+ with patch("httpx.post"):
41
+ daemon.stop()
42
+
43
+ assert daemon._managed_process is None
44
+ mock_proc.wait.assert_called_once()
45
+
46
+
47
+ def test_ensure_running_starts_if_not_running():
48
+ with patch.object(daemon, "is_running", return_value=False), \
49
+ patch.object(daemon, "start") as mock_start:
50
+ daemon.ensure_running()
51
+ mock_start.assert_called_once()
52
+
53
+
54
+ def test_ensure_running_noop_if_running():
55
+ with patch.object(daemon, "is_running", return_value=True), \
56
+ patch.object(daemon, "start") as mock_start:
57
+ daemon.ensure_running()
58
+ mock_start.assert_not_called()
59
+
60
+
61
+ def test_atexit_stop_swallows_exceptions():
62
+ with patch.object(daemon, "stop", side_effect=RuntimeError("boom")):
63
+ daemon._atexit_stop(9009) # should not raise
64
+
65
+
66
+ def test_start_raises_if_process_exits_immediately():
67
+ mock_proc = MagicMock()
68
+ mock_proc.poll.return_value = 1 # exited immediately
69
+ mock_proc.returncode = 1
70
+
71
+ with patch("subprocess.Popen", return_value=mock_proc), \
72
+ patch("builtins.open", MagicMock()), \
73
+ patch.object(daemon, "LOG_DIR", MagicMock()):
74
+ with pytest.raises(RuntimeError, match="exited immediately"):
75
+ daemon.start()
76
+
77
+
78
+ def test_start_raises_if_health_timeout():
79
+ mock_proc = MagicMock()
80
+ mock_proc.poll.return_value = None # still running
81
+
82
+ with patch("subprocess.Popen", return_value=mock_proc), \
83
+ patch("builtins.open", MagicMock()), \
84
+ patch.object(daemon, "LOG_DIR", MagicMock()), \
85
+ patch.object(daemon, "is_running", return_value=False), \
86
+ patch.object(daemon, "HEALTH_TIMEOUT_SECONDS", 0.1), \
87
+ patch.object(daemon, "HEALTH_POLL_INTERVAL_SECONDS", 0.05):
88
+ with pytest.raises(RuntimeError, match="did not become healthy"):
89
+ daemon.start()
90
+ mock_proc.terminate.assert_called_once()
91
+
92
+
93
+ def test_stop_terminates_on_shutdown_timeout():
94
+ mock_proc = MagicMock()
95
+ mock_proc.wait.side_effect = [subprocess.TimeoutExpired("toq", 5), None]
96
+
97
+ daemon._managed_process = mock_proc
98
+ daemon._log_file = MagicMock()
99
+
100
+ import httpx
101
+ with patch("httpx.post", side_effect=httpx.ConnectError("refused")):
102
+ daemon.stop()
103
+
104
+ mock_proc.terminate.assert_called_once()
105
+ assert daemon._managed_process is None
@@ -0,0 +1,61 @@
1
+ """Tests for toq_plugins_common.setup module."""
2
+
3
+ from pathlib import Path
4
+ from unittest.mock import patch, MagicMock
5
+
6
+ from toq_plugins_common import setup
7
+
8
+
9
+ def test_is_configured_true(tmp_path):
10
+ config = tmp_path / "config.toml"
11
+ config.write_text("[agent]")
12
+ with patch.object(setup, "CONFIG_PATH", config):
13
+ assert setup.is_configured() is True
14
+
15
+
16
+ def test_is_configured_false(tmp_path):
17
+ with patch.object(setup, "CONFIG_PATH", tmp_path / "missing.toml"):
18
+ assert setup.is_configured() is False
19
+
20
+
21
+ def test_ensure_configured_noop_when_configured(tmp_path):
22
+ config = tmp_path / "config.toml"
23
+ config.write_text("[agent]")
24
+ with patch.object(setup, "CONFIG_PATH", config), \
25
+ patch("subprocess.run") as mock_run:
26
+ setup.ensure_configured()
27
+ mock_run.assert_not_called()
28
+
29
+
30
+ def test_ensure_configured_runs_setup(tmp_path):
31
+ with patch.object(setup, "CONFIG_PATH", tmp_path / "missing.toml"), \
32
+ patch("toq_plugins_common.setup.binary_path", return_value=Path("/usr/bin/toq")), \
33
+ patch("subprocess.run") as mock_run:
34
+ mock_run.return_value = MagicMock(returncode=0)
35
+ setup.ensure_configured(agent_name="bot", connection_mode="open")
36
+ mock_run.assert_called_once()
37
+ cmd = mock_run.call_args[0][0]
38
+ assert "--agent-name=bot" in cmd
39
+ assert "--connection-mode=open" in cmd
40
+
41
+
42
+ def test_ensure_configured_raises_on_failure(tmp_path):
43
+ import pytest
44
+ with patch.object(setup, "CONFIG_PATH", tmp_path / "missing.toml"), \
45
+ patch("toq_plugins_common.setup.binary_path", return_value=Path("/usr/bin/toq")), \
46
+ patch("subprocess.run") as mock_run:
47
+ mock_run.return_value = MagicMock(returncode=1, stderr="setup failed")
48
+ with pytest.raises(RuntimeError, match="toq setup failed"):
49
+ setup.ensure_configured()
50
+
51
+
52
+ def test_ensure_configured_uses_defaults(tmp_path):
53
+ with patch.object(setup, "CONFIG_PATH", tmp_path / "missing.toml"), \
54
+ patch("toq_plugins_common.setup.binary_path", return_value=Path("/usr/bin/toq")), \
55
+ patch("subprocess.run") as mock_run:
56
+ mock_run.return_value = MagicMock(returncode=0)
57
+ setup.ensure_configured()
58
+ cmd = mock_run.call_args[0][0]
59
+ assert "--agent-name=agent" in cmd
60
+ assert "--connection-mode=approval" in cmd
61
+ assert "--adapter=http" in cmd
@@ -0,0 +1,14 @@
1
+ """Shared daemon management for toq framework plugins."""
2
+
3
+ from toq_plugins_common.binary import ensure_extracted
4
+ from toq_plugins_common.daemon import ensure_running, start, stop
5
+ from toq_plugins_common.setup import ensure_configured, is_configured
6
+
7
+ __all__ = [
8
+ "ensure_extracted",
9
+ "ensure_configured",
10
+ "ensure_running",
11
+ "is_configured",
12
+ "start",
13
+ "stop",
14
+ ]
@@ -0,0 +1,106 @@
1
+ """Binary bundling and extraction for the toq daemon."""
2
+
3
+ import platform
4
+ import shutil
5
+ import stat
6
+ import subprocess
7
+ from pathlib import Path
8
+
9
+ TOQ_HOME = Path.home() / ".toq"
10
+ BIN_DIR = TOQ_HOME / "bin"
11
+ BINARY_NAME = "toq"
12
+ VERSION_CHECK_TIMEOUT_SECONDS = 5
13
+
14
+ # Set by each plugin to point to its bundled binary directory.
15
+ # e.g. Path(__file__).parent / "bin"
16
+ _bundled_bin_dir: Path | None = None
17
+
18
+
19
+ class UnsupportedPlatformError(RuntimeError):
20
+ """Raised when the current platform has no bundled binary."""
21
+
22
+
23
+ def detect_platform() -> str:
24
+ """Return platform tag like 'darwin-aarch64' or 'linux-x86_64'."""
25
+ system = platform.system().lower()
26
+ machine = platform.machine().lower()
27
+
28
+ arch_map = {
29
+ "x86_64": "x86_64",
30
+ "amd64": "x86_64",
31
+ "aarch64": "aarch64",
32
+ "arm64": "aarch64",
33
+ }
34
+
35
+ if system not in ("darwin", "linux"):
36
+ raise UnsupportedPlatformError(f"Unsupported OS: {system}")
37
+
38
+ arch = arch_map.get(machine)
39
+ if arch is None:
40
+ raise UnsupportedPlatformError(f"Unsupported architecture: {machine}")
41
+
42
+ return f"{system}-{arch}"
43
+
44
+
45
+ def binary_path() -> Path:
46
+ """Path where the extracted binary lives."""
47
+ return BIN_DIR / BINARY_NAME
48
+
49
+
50
+ def bundled_binary_path() -> Path:
51
+ """Path to the binary inside the installed plugin wheel."""
52
+ if _bundled_bin_dir is None:
53
+ raise RuntimeError(
54
+ "No bundled binary directory configured. "
55
+ "Call toq_plugins_common.binary.set_bundled_bin_dir() first"
56
+ )
57
+ return _bundled_bin_dir / detect_platform() / BINARY_NAME
58
+
59
+
60
+ def set_bundled_bin_dir(path: Path) -> None:
61
+ """Set the directory containing platform-specific bundled binaries."""
62
+ global _bundled_bin_dir
63
+ _bundled_bin_dir = path
64
+
65
+
66
+ def _get_version(path: Path) -> str | None:
67
+ """Run the binary and return its version string, or None on failure."""
68
+ try:
69
+ result = subprocess.run(
70
+ [str(path), "--version"],
71
+ capture_output=True,
72
+ text=True,
73
+ timeout=VERSION_CHECK_TIMEOUT_SECONDS,
74
+ )
75
+ if result.returncode != 0:
76
+ return None
77
+ return result.stdout.strip()
78
+ except (subprocess.TimeoutExpired, OSError):
79
+ return None
80
+
81
+
82
+ def ensure_extracted() -> Path:
83
+ """Extract the bundled binary to ~/.toq/bin/toq if needed.
84
+
85
+ Skips extraction if the binary exists and versions match.
86
+ Returns the path to the binary.
87
+ """
88
+ target = binary_path()
89
+ bundled = bundled_binary_path()
90
+
91
+ if not bundled.exists():
92
+ raise FileNotFoundError(
93
+ f"No bundled toq binary for {detect_platform()}"
94
+ )
95
+
96
+ if target.exists():
97
+ target_ver = _get_version(target)
98
+ bundled_ver = _get_version(bundled)
99
+ if target_ver and bundled_ver and target_ver == bundled_ver:
100
+ return target
101
+
102
+ BIN_DIR.mkdir(parents=True, exist_ok=True)
103
+ shutil.copy2(bundled, target)
104
+ target.chmod(target.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
105
+
106
+ return target
@@ -0,0 +1,128 @@
1
+ """Daemon lifecycle management for the toq daemon."""
2
+
3
+ import atexit
4
+ import logging
5
+ import subprocess
6
+ import time
7
+
8
+ import httpx
9
+
10
+ from toq_plugins_common.binary import TOQ_HOME, binary_path
11
+
12
+ logger = logging.getLogger("toq_plugins_common.daemon")
13
+
14
+ DEFAULT_PORT = 9009
15
+ HEALTH_TIMEOUT_SECONDS = 10
16
+ HEALTH_POLL_INTERVAL_SECONDS = 0.25
17
+ HEALTH_CHECK_TIMEOUT_SECONDS = 2
18
+ SHUTDOWN_TIMEOUT_SECONDS = 5
19
+ KILL_TIMEOUT_SECONDS = 2
20
+ LOG_DIR = TOQ_HOME / "logs"
21
+
22
+ _managed_process: subprocess.Popen | None = None
23
+ _log_file = None
24
+ _atexit_registered = False
25
+
26
+
27
+ def _health_url(port: int) -> str:
28
+ return f"http://127.0.0.1:{port}/v1/health"
29
+
30
+
31
+ def _shutdown_url(port: int) -> str:
32
+ return f"http://127.0.0.1:{port}/v1/daemon/shutdown"
33
+
34
+
35
+ def is_running(port: int = DEFAULT_PORT) -> bool:
36
+ """Check if the daemon is responding on the given port."""
37
+ try:
38
+ resp = httpx.get(_health_url(port), timeout=HEALTH_CHECK_TIMEOUT_SECONDS)
39
+ return resp.status_code == 200
40
+ except (httpx.ConnectError, httpx.TimeoutException):
41
+ return False
42
+
43
+
44
+ def start(port: int = DEFAULT_PORT) -> subprocess.Popen:
45
+ """Start the toq daemon and wait for it to become healthy."""
46
+ global _managed_process, _log_file, _atexit_registered
47
+
48
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
49
+ _log_file = open(LOG_DIR / "daemon.log", "a")
50
+
51
+ try:
52
+ proc = subprocess.Popen(
53
+ [str(binary_path()), "up", "--foreground"],
54
+ stdout=_log_file,
55
+ stderr=_log_file,
56
+ )
57
+ except Exception:
58
+ _log_file.close()
59
+ _log_file = None
60
+ raise
61
+
62
+ deadline = time.monotonic() + HEALTH_TIMEOUT_SECONDS
63
+ while time.monotonic() < deadline:
64
+ if proc.poll() is not None:
65
+ _log_file.close()
66
+ _log_file = None
67
+ raise RuntimeError(
68
+ f"toq daemon exited immediately (code {proc.returncode})"
69
+ )
70
+ if is_running(port):
71
+ _managed_process = proc
72
+ if not _atexit_registered:
73
+ atexit.register(_atexit_stop, port)
74
+ _atexit_registered = True
75
+ return proc
76
+ time.sleep(HEALTH_POLL_INTERVAL_SECONDS)
77
+
78
+ proc.terminate()
79
+ _log_file.close()
80
+ _log_file = None
81
+ raise RuntimeError(
82
+ f"toq daemon did not become healthy within {HEALTH_TIMEOUT_SECONDS}s"
83
+ )
84
+
85
+
86
+ def stop(port: int = DEFAULT_PORT) -> None:
87
+ """Stop the daemon if we started it."""
88
+ global _managed_process, _log_file
89
+
90
+ if _managed_process is None:
91
+ return
92
+
93
+ try:
94
+ httpx.post(
95
+ _shutdown_url(port),
96
+ json={"graceful": True},
97
+ timeout=SHUTDOWN_TIMEOUT_SECONDS,
98
+ )
99
+ except (httpx.ConnectError, httpx.TimeoutException):
100
+ pass
101
+
102
+ try:
103
+ _managed_process.wait(timeout=SHUTDOWN_TIMEOUT_SECONDS)
104
+ except subprocess.TimeoutExpired:
105
+ _managed_process.terminate()
106
+ try:
107
+ _managed_process.wait(timeout=KILL_TIMEOUT_SECONDS)
108
+ except subprocess.TimeoutExpired:
109
+ _managed_process.kill()
110
+
111
+ _managed_process = None
112
+ if _log_file is not None:
113
+ _log_file.close()
114
+ _log_file = None
115
+
116
+
117
+ def ensure_running(port: int = DEFAULT_PORT) -> None:
118
+ """Start the daemon if it's not already running."""
119
+ if not is_running(port):
120
+ start(port)
121
+
122
+
123
+ def _atexit_stop(port: int) -> None:
124
+ """Registered with atexit to clean up the daemon on exit."""
125
+ try:
126
+ stop(port)
127
+ except Exception:
128
+ pass
@@ -0,0 +1,45 @@
1
+ """Programmatic setup for the toq daemon."""
2
+
3
+ import subprocess
4
+
5
+ from toq_plugins_common.binary import TOQ_HOME, binary_path
6
+
7
+ CONFIG_PATH = TOQ_HOME / "config.toml"
8
+
9
+ DEFAULT_AGENT_NAME = "agent"
10
+ DEFAULT_CONNECTION_MODE = "approval"
11
+ DEFAULT_ADAPTER = "http"
12
+ SETUP_TIMEOUT_SECONDS = 30
13
+
14
+
15
+ def is_configured() -> bool:
16
+ """Return True if toq setup has been completed."""
17
+ return CONFIG_PATH.exists()
18
+
19
+
20
+ def ensure_configured(
21
+ *,
22
+ agent_name: str | None = None,
23
+ connection_mode: str | None = None,
24
+ adapter: str | None = None,
25
+ ) -> None:
26
+ """Run toq setup --non-interactive if not already configured.
27
+
28
+ If config already exists, this is a no-op. Parameters only apply
29
+ to fresh setup.
30
+ """
31
+ if is_configured():
32
+ return
33
+
34
+ cmd = [
35
+ str(binary_path()),
36
+ "setup",
37
+ "--non-interactive",
38
+ f"--agent-name={agent_name or DEFAULT_AGENT_NAME}",
39
+ f"--connection-mode={connection_mode or DEFAULT_CONNECTION_MODE}",
40
+ f"--adapter={adapter or DEFAULT_ADAPTER}",
41
+ ]
42
+
43
+ result = subprocess.run(cmd, capture_output=True, text=True, timeout=SETUP_TIMEOUT_SECONDS)
44
+ if result.returncode != 0:
45
+ raise RuntimeError(f"toq setup failed: {result.stderr.strip()}")