trackwright 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.
Files changed (46) hide show
  1. trackwright/__init__.py +13 -0
  2. trackwright/__main__.py +7 -0
  3. trackwright/bridge.py +323 -0
  4. trackwright/cli.py +203 -0
  5. trackwright/commands.py +397 -0
  6. trackwright/config.py +149 -0
  7. trackwright/core/__init__.py +1 -0
  8. trackwright/core/automation.py +410 -0
  9. trackwright/core/base.py +313 -0
  10. trackwright/core/commanding.py +300 -0
  11. trackwright/core/exporting.py +143 -0
  12. trackwright/core/importing.py +127 -0
  13. trackwright/core/lifecycle.py +74 -0
  14. trackwright/core/models.py +467 -0
  15. trackwright/core/policy.py +58 -0
  16. trackwright/core/presets.py +88 -0
  17. trackwright/core/project.py +221 -0
  18. trackwright/core/ranges.py +355 -0
  19. trackwright/core/reading.py +229 -0
  20. trackwright/core/refs.py +88 -0
  21. trackwright/core/saving.py +72 -0
  22. trackwright/core/service.py +27 -0
  23. trackwright/core/settings.py +80 -0
  24. trackwright/core/writing.py +390 -0
  25. trackwright/coremidi.py +307 -0
  26. trackwright/cubase/Trackwright_Bridge.js +1130 -0
  27. trackwright/cubase/__init__.py +83 -0
  28. trackwright/doctor.py +69 -0
  29. trackwright/errors.py +120 -0
  30. trackwright/exports.py +54 -0
  31. trackwright/fake.py +1112 -0
  32. trackwright/host.py +238 -0
  33. trackwright/hostport.py +74 -0
  34. trackwright/installer.py +305 -0
  35. trackwright/launcher.py +150 -0
  36. trackwright/logs.py +50 -0
  37. trackwright/ops.py +46 -0
  38. trackwright/protocol.py +182 -0
  39. trackwright/py.typed +0 -0
  40. trackwright/server.py +472 -0
  41. trackwright/transport.py +130 -0
  42. trackwright-0.3.0.dist-info/METADATA +190 -0
  43. trackwright-0.3.0.dist-info/RECORD +46 -0
  44. trackwright-0.3.0.dist-info/WHEEL +4 -0
  45. trackwright-0.3.0.dist-info/entry_points.txt +3 -0
  46. trackwright-0.3.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,13 @@
1
+ """Trackwright: an MCP server that inspects and controls Cubase through a MIDI Remote script."""
2
+
3
+ from importlib.metadata import PackageNotFoundError, version
4
+
5
+ try:
6
+ __version__ = version("trackwright")
7
+ except PackageNotFoundError: # pragma: no cover - only when running from a raw source tree
8
+ __version__ = "0.0.0"
9
+
10
+ SERVER_NAME = "trackwright"
11
+ """Identifier of the MCP server."""
12
+
13
+ __all__ = ["SERVER_NAME", "__version__"]
@@ -0,0 +1,7 @@
1
+ """``python -m trackwright`` is the same as the ``trackwright`` executable."""
2
+
3
+ import sys
4
+
5
+ from trackwright.cli import main
6
+
7
+ sys.exit(main())
trackwright/bridge.py ADDED
@@ -0,0 +1,323 @@
1
+ """Bridge daemon and its local IPC client.
2
+
3
+ The bridge is a long-running process that owns the virtual MIDI ports and the
4
+ :class:`~trackwright.host.HostClient`. MCP servers (one per client session) talk to it over a
5
+ Unix domain socket in a private per-user directory (mode 0700, socket 0600). No network port is
6
+ ever opened.
7
+
8
+ IPC: one JSON line per connection, at most 64 KiB::
9
+
10
+ request: {"op": str, "p": {...}, "timeout": 0.05..30}
11
+ response: {"ok": true, "result": {...}, "meta": {...}}
12
+ {"ok": false, "error": {"code": str, "message": str, "details": {...}}}
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ import platform
20
+ import socket
21
+ import socketserver
22
+ import stat
23
+ import threading
24
+ import time
25
+ from collections.abc import Callable
26
+ from pathlib import Path
27
+ from typing import Any, Protocol
28
+
29
+ from loguru import logger
30
+
31
+ from trackwright.config import BridgeConfig
32
+ from trackwright.errors import (
33
+ BridgeUnavailableError,
34
+ ProtocolError,
35
+ RequestTimeoutError,
36
+ TrackwrightError,
37
+ )
38
+ from trackwright.host import Reply
39
+ from trackwright.ops import ALL_OPS, WRITE_OPS
40
+ from trackwright.transport import Transport
41
+
42
+ MAX_LINE = 64 * 1024
43
+ MIN_TIMEOUT = 0.05
44
+ MAX_TIMEOUT = 30.0
45
+ LOCAL_OPS = frozenset({"bridge.status"})
46
+ JsonObject = dict[str, Any]
47
+
48
+
49
+ class HostBackend(Protocol):
50
+ """The part of :class:`~trackwright.host.HostClient` the bridge needs (a fake in tests)."""
51
+
52
+ session_id: str | None
53
+
54
+ def call(self, op: str, params: JsonObject | None = None, *, timeout: float | None = None) -> Reply: ...
55
+
56
+ def stats(self) -> JsonObject: ...
57
+
58
+
59
+ # ---------------------------------------------------------------------------- validation
60
+ def validate_request(obj: object) -> tuple[str, JsonObject, float]:
61
+ """Validate one IPC request; return ``(op, params, timeout)``."""
62
+ if not isinstance(obj, dict):
63
+ raise ProtocolError("IPC request must be a JSON object")
64
+ op = obj.get("op")
65
+ if not isinstance(op, str) or (op not in ALL_OPS and op not in LOCAL_OPS):
66
+ raise ProtocolError(f"operation not allowed: {op!r}")
67
+ params = obj.get("p") or {}
68
+ if not isinstance(params, dict):
69
+ raise ProtocolError("'p' must be a JSON object")
70
+ timeout = obj.get("timeout", 3.0)
71
+ if isinstance(timeout, bool) or not isinstance(timeout, int | float) or not MIN_TIMEOUT <= timeout <= MAX_TIMEOUT:
72
+ raise ProtocolError(f"timeout must be a number within [{MIN_TIMEOUT}, {MAX_TIMEOUT}] seconds")
73
+ return op, params, float(timeout)
74
+
75
+
76
+ # ---------------------------------------------------------------------------- socket directory
77
+ def prepare_socket_dir(directory: Path) -> Path:
78
+ """Create the private socket directory (0700) and refuse it if another user could use it."""
79
+ directory.mkdir(mode=0o700, parents=True, exist_ok=True)
80
+ info = directory.stat()
81
+ if info.st_uid != os.getuid():
82
+ raise BridgeUnavailableError(f"socket directory {directory} belongs to another user")
83
+ if stat.S_IMODE(info.st_mode) & 0o077:
84
+ directory.chmod(0o700)
85
+ if stat.S_IMODE(directory.stat().st_mode) & 0o077:
86
+ raise BridgeUnavailableError(f"cannot make {directory} private (mode 0700)")
87
+ return directory
88
+
89
+
90
+ def _socket_is_alive(path: Path) -> bool:
91
+ try:
92
+ with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as probe:
93
+ probe.settimeout(0.5)
94
+ probe.connect(str(path))
95
+ except ConnectionRefusedError, FileNotFoundError, TimeoutError, OSError:
96
+ return False
97
+ return True
98
+
99
+
100
+ # ---------------------------------------------------------------------------- daemon
101
+ class Bridge:
102
+ """Request dispatcher, independent of sockets so it can be unit tested."""
103
+
104
+ def __init__(self, host: HostBackend) -> None:
105
+ self.host = host
106
+ self.started_at = time.time()
107
+ self.requests = 0
108
+ self.errors = 0
109
+
110
+ def status(self) -> JsonObject:
111
+ return {
112
+ "pid": os.getpid(),
113
+ "uptime_s": round(time.time() - self.started_at, 1),
114
+ "requests": self.requests,
115
+ "errors": self.errors,
116
+ "host": self.host.stats(),
117
+ }
118
+
119
+ def handle(self, obj: object) -> JsonObject:
120
+ self.requests += 1
121
+ started = time.monotonic()
122
+ try:
123
+ op, params, timeout = validate_request(obj)
124
+ if op == "bridge.status":
125
+ return {"ok": True, "result": self.status()}
126
+ reply = self.host.call(op, params, timeout=timeout)
127
+ except TrackwrightError as exc:
128
+ self.errors += 1
129
+ logger.debug("request failed: {}", exc)
130
+ return {"ok": False, "error": exc.to_dict(), "meta": self._meta(started)}
131
+ except Exception as exc:
132
+ self.errors += 1
133
+ logger.exception("unexpected error handling {}", obj)
134
+ op_name = obj.get("op") if isinstance(obj, dict) else None
135
+ outcome = "unknown" if op_name in WRITE_OPS else "not_executed_or_lost"
136
+ error = TrackwrightError(
137
+ f"internal bridge error: {exc}", code="INTERNAL_ERROR", details={"op": op_name, "outcome": outcome}
138
+ )
139
+ return {"ok": False, "error": error.to_dict(), "meta": self._meta(started)}
140
+ logger.debug("{} ok in {:.1f} ms (via {})", op, reply.duration_ms, reply.via)
141
+ meta = self._meta(started) | {"rid": reply.rid, "via": reply.via, "duplicate": reply.duplicate}
142
+ return {"ok": True, "result": reply.result, "meta": meta}
143
+
144
+ def _meta(self, started: float) -> JsonObject:
145
+ return {"duration_ms": round((time.monotonic() - started) * 1000, 2), "session_id": self.host.session_id}
146
+
147
+
148
+ class _Handler(socketserver.StreamRequestHandler):
149
+ bridge: Bridge
150
+
151
+ def handle(self) -> None:
152
+ line = self.rfile.readline(MAX_LINE + 1)
153
+ if not line:
154
+ return # a liveness probe: connected and closed without a request
155
+ if len(line) > MAX_LINE:
156
+ response: JsonObject = {"ok": False, "error": ProtocolError("IPC line too long").to_dict()}
157
+ else:
158
+ try:
159
+ response = self.bridge.handle(json.loads(line))
160
+ except ValueError as exc:
161
+ response = {"ok": False, "error": ProtocolError(f"invalid JSON: {exc}").to_dict()}
162
+ try:
163
+ self.wfile.write((json.dumps(response) + "\n").encode())
164
+ except BrokenPipeError:
165
+ logger.warning("IPC client disconnected before the response")
166
+
167
+
168
+ class BridgeServer:
169
+ """Unix socket server around a :class:`Bridge`."""
170
+
171
+ def __init__(self, bridge: Bridge, socket_path: Path) -> None:
172
+ self.bridge = bridge
173
+ self.socket_path = socket_path
174
+ self._server: socketserver.ThreadingUnixStreamServer | None = None
175
+
176
+ def start(self) -> None:
177
+ prepare_socket_dir(self.socket_path.parent)
178
+ if self.socket_path.exists():
179
+ if _socket_is_alive(self.socket_path):
180
+ raise BridgeUnavailableError(f"another bridge is already listening on {self.socket_path}")
181
+ self.socket_path.unlink()
182
+ handler = type("BoundHandler", (_Handler,), {"bridge": self.bridge})
183
+ previous = os.umask(0o077)
184
+ try:
185
+ self._server = socketserver.ThreadingUnixStreamServer(str(self.socket_path), handler)
186
+ finally:
187
+ os.umask(previous)
188
+ self._server.daemon_threads = True
189
+ self.socket_path.chmod(0o600)
190
+ logger.info("bridge listening on {}", self.socket_path)
191
+
192
+ def serve_forever(self) -> None:
193
+ if self._server is None:
194
+ self.start()
195
+ assert self._server is not None
196
+ self._server.serve_forever(poll_interval=0.2)
197
+
198
+ def serve_in_thread(self) -> threading.Thread:
199
+ """Start serving in a daemon thread (used by tests)."""
200
+ if self._server is None:
201
+ self.start()
202
+ thread = threading.Thread(target=self.serve_forever, name="trackwright-bridge", daemon=True)
203
+ thread.start()
204
+ return thread
205
+
206
+ def stop(self) -> None:
207
+ if self._server is not None:
208
+ self._server.shutdown()
209
+ self._server.server_close()
210
+ self._server = None
211
+ self.socket_path.unlink(missing_ok=True)
212
+ logger.info("bridge stopped")
213
+
214
+
215
+ # ---------------------------------------------------------------------------- client
216
+ def error_from_dict(error: JsonObject) -> TrackwrightError:
217
+ from trackwright import errors as module # noqa: PLC0415 - small local lookup table
218
+
219
+ code = str(error.get("code", "BRIDGE_ERROR"))
220
+ message = str(error.get("message", ""))
221
+ details = error.get("details") if isinstance(error.get("details"), dict) else None
222
+ if code == "TIMEOUT":
223
+ outcome_unknown = bool(details and details.get("outcome") == "unknown")
224
+ return RequestTimeoutError(message, outcome_unknown=outcome_unknown, details=details)
225
+ if code == "STALE_SESSION":
226
+ return module.StaleSessionError(message, details=details)
227
+ return TrackwrightError(message, code=code, details=details)
228
+
229
+
230
+ class BridgeClient:
231
+ """Client used by the MCP server and the CLI to reach the bridge."""
232
+
233
+ def __init__(self, socket_path: Path, *, connect: Callable[[], socket.socket] | None = None) -> None:
234
+ self.socket_path = socket_path
235
+ self._connect = connect or (lambda: socket.socket(socket.AF_UNIX, socket.SOCK_STREAM))
236
+
237
+ def request(self, op: str, params: JsonObject | None = None, *, timeout: float = 3.0) -> JsonObject:
238
+ """Send one request; return the full response (``ok``, ``result`` or ``error``, ``meta``)."""
239
+ line = json.dumps({"op": op, "p": params or {}, "timeout": timeout}) + "\n"
240
+ buffer = b""
241
+ try:
242
+ with self._connect() as sock:
243
+ sock.settimeout(timeout + 2.0)
244
+ sock.connect(str(self.socket_path))
245
+ sock.sendall(line.encode())
246
+ while not buffer.endswith(b"\n"):
247
+ chunk = sock.recv(65536)
248
+ if not chunk:
249
+ break
250
+ buffer += chunk
251
+ except FileNotFoundError as exc:
252
+ raise BridgeUnavailableError(
253
+ f"bridge socket not found at {self.socket_path}: start it with 'trackwright bridge'",
254
+ details={"sent": False},
255
+ ) from exc
256
+ except ConnectionRefusedError as exc:
257
+ raise BridgeUnavailableError(
258
+ f"bridge is not accepting connections at {self.socket_path}", details={"sent": False}
259
+ ) from exc
260
+ except TimeoutError as exc:
261
+ raise RequestTimeoutError(
262
+ f"no answer from the bridge for {op}", outcome_unknown=True, details={"op": op}
263
+ ) from exc
264
+ if not buffer:
265
+ raise BridgeUnavailableError("the bridge closed the connection without answering")
266
+ try:
267
+ response = json.loads(buffer)
268
+ except ValueError as exc:
269
+ raise ProtocolError(f"invalid response from the bridge: {exc}") from exc
270
+ if not isinstance(response, dict):
271
+ raise ProtocolError("the bridge response is not a JSON object")
272
+ return response
273
+
274
+ def call(self, op: str, params: JsonObject | None = None, *, timeout: float = 3.0) -> JsonObject:
275
+ """Like :meth:`request` but return ``result`` or raise the error it carries."""
276
+ response = self.request(op, params, timeout=timeout)
277
+ if not response.get("ok"):
278
+ error = response.get("error")
279
+ raise error_from_dict(error if isinstance(error, dict) else {})
280
+ result = response.get("result")
281
+ return result if isinstance(result, dict) else {}
282
+
283
+
284
+ # ---------------------------------------------------------------------------- entry point
285
+ def make_transport(settings: BridgeConfig, *, system: str | None = None) -> Transport:
286
+ """The configured MIDI transport (TW-1302): CoreMIDI through ctypes on macOS by default."""
287
+ backend = settings.backend
288
+ if backend == "auto":
289
+ backend = "coremidi" if (system or platform.system()) == "Darwin" else "rtmidi"
290
+ if backend == "coremidi":
291
+ from trackwright.coremidi import CoreMidiTransport # noqa: PLC0415
292
+
293
+ return CoreMidiTransport(settings.to_cubase_port, settings.from_cubase_port)
294
+ from trackwright.transport import MidiTransport # noqa: PLC0415 - needs mido + python-rtmidi installed
295
+
296
+ return MidiTransport(settings.to_cubase_port, settings.from_cubase_port)
297
+
298
+
299
+ def run_bridge(
300
+ settings: BridgeConfig, *, transport: Transport | None = None, stop: threading.Event | None = None
301
+ ) -> None:
302
+ """Run the bridge until ``stop`` is set or SIGINT/SIGTERM arrives."""
303
+ import signal # noqa: PLC0415 - only needed by the daemon
304
+
305
+ from trackwright.host import HostClient # noqa: PLC0415
306
+
307
+ stop_event = stop or threading.Event()
308
+ host = HostClient(
309
+ transport or make_transport(settings),
310
+ default_timeout=settings.request_timeout,
311
+ )
312
+ host.open()
313
+ server = BridgeServer(Bridge(host), settings.socket_path)
314
+ try:
315
+ server.serve_in_thread()
316
+ if threading.current_thread() is threading.main_thread():
317
+ for signum in (signal.SIGINT, signal.SIGTERM):
318
+ signal.signal(signum, lambda *_: stop_event.set())
319
+ logger.info("bridge ready; waiting for Cubase on '{}'", settings.to_cubase_port)
320
+ stop_event.wait()
321
+ finally:
322
+ server.stop()
323
+ host.close()
trackwright/cli.py ADDED
@@ -0,0 +1,203 @@
1
+ """Command line entry point: ``trackwright <command>``.
2
+
3
+ Commands print JSON on stdout (so they can be scripted) and log to stderr. ``serve`` is the
4
+ exception: stdout belongs to the MCP stdio protocol, so it prints nothing itself.
5
+ Heavy modules are imported inside each handler to keep ``--help`` fast.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import sys
13
+ from collections.abc import Callable, Sequence
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from loguru import logger
18
+
19
+ from trackwright import __version__
20
+ from trackwright.config import Settings, config_path, load_settings
21
+ from trackwright.errors import TrackwrightError
22
+ from trackwright.logs import configure_logging
23
+
24
+ Handler = Callable[[argparse.Namespace, Settings], int]
25
+
26
+
27
+ def emit(payload: Any) -> None:
28
+ """Print a JSON document on stdout."""
29
+ sys.stdout.write(json.dumps(payload, indent=2, ensure_ascii=False, default=str) + "\n")
30
+
31
+
32
+ def cmd_version(_args: argparse.Namespace, _settings: Settings) -> int:
33
+ emit({"trackwright": __version__})
34
+ return 0
35
+
36
+
37
+ def cmd_config(_args: argparse.Namespace, settings: Settings) -> int:
38
+ data = settings.model_dump(mode="json")
39
+ data["bridge"]["socket_path"] = str(settings.bridge.socket_path)
40
+ emit(data)
41
+ return 0
42
+
43
+
44
+ def cmd_bridge(_args: argparse.Namespace, settings: Settings) -> int:
45
+ from trackwright.bridge import run_bridge # noqa: PLC0415
46
+
47
+ run_bridge(settings.bridge)
48
+ return 0
49
+
50
+
51
+ def cmd_bridge_status(_args: argparse.Namespace, settings: Settings) -> int:
52
+ from trackwright.bridge import BridgeClient # noqa: PLC0415
53
+
54
+ emit(BridgeClient(settings.bridge.socket_path).call("bridge.status", timeout=settings.bridge.request_timeout))
55
+ return 0
56
+
57
+
58
+ def cmd_ping(args: argparse.Namespace, settings: Settings) -> int:
59
+ from trackwright.bridge import BridgeClient # noqa: PLC0415
60
+
61
+ client = BridgeClient(settings.bridge.socket_path)
62
+ timeout = settings.bridge.request_timeout
63
+ hello = client.call("hello", timeout=timeout)
64
+ pong = client.request("ping", {"echo": args.echo}, timeout=timeout)
65
+ emit({"ok": bool(pong.get("ok")), "hello": hello, "ping": pong})
66
+ return 0 if pong.get("ok") else 1
67
+
68
+
69
+ def cmd_script(args: argparse.Namespace, settings: Settings) -> int:
70
+ from trackwright.cubase import SCRIPT_PATH, install_script, script_status # noqa: PLC0415
71
+
72
+ install_dir = args.dir or settings.script.install_dir
73
+ source = args.file.resolve() if args.file else SCRIPT_PATH
74
+ if args.action == "status":
75
+ emit(script_status(install_dir, source).to_dict())
76
+ return 0
77
+ status, written = install_script(install_dir, apply=args.apply, source=source)
78
+ payload = {**status.to_dict(), "written": written, "dry_run": not args.apply}
79
+ if not args.apply and status.state != "up_to_date":
80
+ payload["next_step"] = "re-run with --apply to copy the script, then restart Cubase"
81
+ elif written:
82
+ payload["next_step"] = "restart Cubase (it does not reload changed scripts by itself)"
83
+ emit(payload)
84
+ return 0
85
+
86
+
87
+ def make_service(settings: Settings, *, simulate: bool) -> Any:
88
+ """The Cubase service backed by the bridge, or by the simulator with ``simulate``."""
89
+ from trackwright.core.policy import WritePolicy # noqa: PLC0415
90
+ from trackwright.core.service import CubaseService # noqa: PLC0415
91
+
92
+ policy = WritePolicy.from_config(settings.writes)
93
+ if simulate:
94
+ from trackwright.fake import FakeCubase, FakeExports, FakeLauncher # noqa: PLC0415
95
+
96
+ cubase = FakeCubase()
97
+ return CubaseService(cubase, policy, launcher=FakeLauncher(cubase), exports=FakeExports()) # type: ignore[arg-type]
98
+ from trackwright.bridge import BridgeClient # noqa: PLC0415
99
+ from trackwright.hostport import BridgeHost # noqa: PLC0415
100
+ from trackwright.launcher import BridgeLauncher, CubaseLauncher # noqa: PLC0415
101
+
102
+ bridge_launcher = BridgeLauncher(settings.bridge, config_path=settings.source)
103
+ host = BridgeHost(
104
+ BridgeClient(settings.bridge.socket_path),
105
+ read_timeout=settings.bridge.request_timeout,
106
+ write_timeout=settings.bridge.write_timeout,
107
+ ensure_bridge=bridge_launcher.ensure if settings.bridge.autostart else None,
108
+ )
109
+ return CubaseService(host, policy, launcher=CubaseLauncher(settings.cubase))
110
+
111
+
112
+ def cmd_serve(args: argparse.Namespace, settings: Settings) -> int:
113
+ from trackwright.server import build_server # noqa: PLC0415
114
+
115
+ server = build_server(make_service(settings, simulate=args.simulate))
116
+ logger.info("MCP server '{}' on stdio (simulate={})", server.name, args.simulate)
117
+ server.run(transport="stdio")
118
+ return 0
119
+
120
+
121
+ def cmd_doctor(_args: argparse.Namespace, settings: Settings) -> int:
122
+ """Check every link of the chain and say what to do next."""
123
+ from trackwright.doctor import run_doctor # noqa: PLC0415
124
+
125
+ report = run_doctor(settings)
126
+ emit(report)
127
+ return 0 if report["ok"] else 1
128
+
129
+
130
+ def cmd_setup(args: argparse.Namespace, settings: Settings) -> int:
131
+ """Guided installation (TW-1301): every step is shown and confirmed before it is applied."""
132
+ from trackwright.installer import ConsolePrompter, Setup # noqa: PLC0415
133
+
134
+ config_file, _explicit = config_path(args.config)
135
+ result = Setup(
136
+ settings, config_file, ConsolePrompter(), assume_yes=args.yes, writes=args.writes, save=args.save
137
+ ).run()
138
+ emit(result)
139
+ return 0 if result["ok"] else 1
140
+
141
+
142
+ def build_parser() -> argparse.ArgumentParser:
143
+ parser = argparse.ArgumentParser(prog="trackwright", description="Trackwright: MCP server for Cubase.")
144
+ parser.add_argument("--config", type=Path, help="configuration file (default: $TRACKWRIGHT_CONFIG or ~/.config)")
145
+ parser.add_argument("--log-level", help="override the configured log level")
146
+ sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
147
+ sub.add_parser("version", help="print the version").set_defaults(handler=cmd_version)
148
+ sub.add_parser("config", help="print the effective configuration").set_defaults(handler=cmd_config)
149
+ sub.add_parser("bridge", help="run the bridge daemon (keep it running)").set_defaults(handler=cmd_bridge)
150
+ sub.add_parser("bridge-status", help="show the bridge status").set_defaults(handler=cmd_bridge_status)
151
+ serve = sub.add_parser("serve", help="run the MCP server on stdio (started by the MCP client)")
152
+ serve.add_argument("--simulate", action="store_true", help="answer from the built-in Cubase simulator")
153
+ serve.set_defaults(handler=cmd_serve)
154
+ sub.add_parser("doctor", help="diagnose the whole chain and suggest fixes").set_defaults(handler=cmd_doctor)
155
+ setup = sub.add_parser("setup", help="guided installation: Cubase script, configuration, Claude Code, doctor")
156
+ setup.add_argument(
157
+ "--yes",
158
+ action="append",
159
+ default=[],
160
+ choices=["script", "config", "claude"],
161
+ metavar="STEP",
162
+ help="apply STEP (script, config or claude) without asking; repeat it for several steps",
163
+ )
164
+ setup.add_argument(
165
+ "--writes", action=argparse.BooleanOptionalAction, help="new configuration: allow writes (default: ask; no)"
166
+ )
167
+ setup.add_argument(
168
+ "--save", action=argparse.BooleanOptionalAction, help="new configuration: allow saving (default: ask; no)"
169
+ )
170
+ setup.set_defaults(handler=cmd_setup)
171
+ script = sub.add_parser("script", help="install or check the Cubase MIDI Remote script")
172
+ script.add_argument("action", choices=["status", "install"])
173
+ script.add_argument("--dir", type=Path, help="override [script] install_dir")
174
+ script.add_argument("--apply", action="store_true", help="really copy the file (default: dry run)")
175
+ script.add_argument("--file", type=Path, help="install this script instead of the packaged one (experiments)")
176
+ script.set_defaults(handler=cmd_script)
177
+ ping = sub.add_parser("ping", help="check the round trip Python -> Cubase -> Python")
178
+ ping.add_argument("--echo", default="hola-ñ-✓", help="text echoed back by the script")
179
+ ping.set_defaults(handler=cmd_ping)
180
+ return parser
181
+
182
+
183
+ def main(argv: Sequence[str] | None = None) -> int:
184
+ args = build_parser().parse_args(argv)
185
+ try:
186
+ settings = load_settings(args.config, missing_ok=args.command == "setup")
187
+ except TrackwrightError as exc:
188
+ configure_logging("INFO")
189
+ logger.error("{}", exc)
190
+ emit({"ok": False, "error": exc.to_dict()})
191
+ return 2
192
+ configure_logging(args.log_level or settings.logging.level, settings.logging.file)
193
+ handler: Handler = args.handler
194
+ try:
195
+ return handler(args, settings)
196
+ except TrackwrightError as exc:
197
+ logger.error("{}", exc)
198
+ emit({"ok": False, "error": exc.to_dict()})
199
+ return 1
200
+
201
+
202
+ if __name__ == "__main__": # pragma: no cover
203
+ sys.exit(main())