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.
- trackwright/__init__.py +13 -0
- trackwright/__main__.py +7 -0
- trackwright/bridge.py +323 -0
- trackwright/cli.py +203 -0
- trackwright/commands.py +397 -0
- trackwright/config.py +149 -0
- trackwright/core/__init__.py +1 -0
- trackwright/core/automation.py +410 -0
- trackwright/core/base.py +313 -0
- trackwright/core/commanding.py +300 -0
- trackwright/core/exporting.py +143 -0
- trackwright/core/importing.py +127 -0
- trackwright/core/lifecycle.py +74 -0
- trackwright/core/models.py +467 -0
- trackwright/core/policy.py +58 -0
- trackwright/core/presets.py +88 -0
- trackwright/core/project.py +221 -0
- trackwright/core/ranges.py +355 -0
- trackwright/core/reading.py +229 -0
- trackwright/core/refs.py +88 -0
- trackwright/core/saving.py +72 -0
- trackwright/core/service.py +27 -0
- trackwright/core/settings.py +80 -0
- trackwright/core/writing.py +390 -0
- trackwright/coremidi.py +307 -0
- trackwright/cubase/Trackwright_Bridge.js +1130 -0
- trackwright/cubase/__init__.py +83 -0
- trackwright/doctor.py +69 -0
- trackwright/errors.py +120 -0
- trackwright/exports.py +54 -0
- trackwright/fake.py +1112 -0
- trackwright/host.py +238 -0
- trackwright/hostport.py +74 -0
- trackwright/installer.py +305 -0
- trackwright/launcher.py +150 -0
- trackwright/logs.py +50 -0
- trackwright/ops.py +46 -0
- trackwright/protocol.py +182 -0
- trackwright/py.typed +0 -0
- trackwright/server.py +472 -0
- trackwright/transport.py +130 -0
- trackwright-0.3.0.dist-info/METADATA +190 -0
- trackwright-0.3.0.dist-info/RECORD +46 -0
- trackwright-0.3.0.dist-info/WHEEL +4 -0
- trackwright-0.3.0.dist-info/entry_points.txt +3 -0
- trackwright-0.3.0.dist-info/licenses/LICENSE +661 -0
trackwright/__init__.py
ADDED
|
@@ -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__"]
|
trackwright/__main__.py
ADDED
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())
|