forked from 1b-agent/opencadstudio-upstream
Add persistent live OpenCADStudio control
This commit is contained in:
1 parent
9d991d7feb
commit
28cc473062
22 files changed
+1764
-95
No files matched your search
+16
-62
@@ -1,73 +1,27 @@
|
||||
# Headless automation API
|
||||
# OpenCADStudio automation
|
||||
|
||||
Open CAD Studio can run without a GUI and be driven over a line-based JSON
|
||||
protocol — for scripts, batch jobs, or AI agents.
|
||||
`--serve` remains available for isolated headless scripts. `live.py` connects to the real desktop application through a private per-process descriptor under the user's OCS configuration directory. The GUI owns every mutation: transport threads only validate and enqueue requests.
|
||||
|
||||
```sh
|
||||
OpenCADStudio --serve # stdin/stdout transport
|
||||
OpenCADStudio --serve --port 4242 # listen on 127.0.0.1:4242 instead
|
||||
```
|
||||
The live protocol requires protocol version 1, stable session/document IDs, an expected edit revision for mutations, and a unique request ID. Commands report `waiting_input` while interactive work remains. Asynchronous file and renderer work remains `running` until its real callback finishes. A bounded operation cache makes retries with the same payload idempotent; after a restart or an expired operation, clients must read state instead of replaying a mutation.
|
||||
|
||||
It reads one JSON request per line and writes one JSON response per line — over
|
||||
**stdin/stdout**, or over a **local TCP socket** with `--port`. The active
|
||||
document persists across requests (and, on the socket, across reconnects), so a
|
||||
caller can act → observe → act.
|
||||
Operations include document state and activation, paged entity queries, layer/header/property inspection, command discovery, start/run/step/cancel, WCS/UCS/relative points, entity and structure picks, selection, property edits, semantic UI actions, undo/redo, protected save, event polling, and current-window PNG capture. The status bar includes a control switch whose green, yellow, and red states mean ready, busy, and off.
|
||||
|
||||
## Protocol
|
||||
The Web build exposes `ocs_control_submit(json)` and `ocs_control_take(ticket)`. They enter the same bounded GUI queue and return the same semantic responses; browser code polls the ticket until a response is present.
|
||||
|
||||
| Request | Response |
|
||||
|---------|----------|
|
||||
| `{"op":"new"}` | `{"ok":true,"total":0,"by_type":{}}` |
|
||||
| `{"op":"open","path":"plan.dwg"}` | entity summary |
|
||||
| `{"op":"run","cmd":"LAYER Walls"}` | `{"ok":true,"cmd":...,"entities":N,"added":D}` |
|
||||
| `{"op":"entities"}` | `{"ok":true,"total":N,"by_type":{"Line":42,...}}` |
|
||||
| `{"op":"query","type":"Line","layer":"Walls"}` | per-entity `{handle,type,layer,…geometry}` (Line/Circle/Arc/Point/Ellipse/Text/MText/Polyline/Insert; filters + `limit` optional) |
|
||||
| `{"op":"layers"}` | layers `{name,color,off,frozen,locked}` + the current layer |
|
||||
| `{"op":"header"}` | drawing variables (units, PDMODE/PDSIZE, LTSCALE, …) |
|
||||
| `{"op":"select","handles":["2B"]}` | set the selection (by `handles`, `type`, or `layer`; `clear` to deselect) → `{"ok":true,"selected":N}` |
|
||||
| `{"op":"undo"}` / `{"op":"redo"}` | step the document history → entity summary |
|
||||
| `{"op":"save","path":"out.dwg"}` | `{"ok":true,"saved":"out.dwg"}` (path optional once opened/saved) |
|
||||
Run `./docs/automation/install.sh` once. It installs the release binary and pinned MCP environment in `~/.local/share/ocs-control`, registers the `opencadstudio` stdio server in the user's Codex configuration, and leaves source-independent launch paths. The personal `opencadstudio-control` skill tells later Codex sessions how to use it safely.
|
||||
|
||||
Selection drives modify commands — `select` the targets (e.g. the handles a
|
||||
`query` returned), then `run("ERASE")`:
|
||||
|
||||
```python
|
||||
ocs.select(type="Line"); ocs.run("ERASE") # erase every line
|
||||
ids = [e["handle"] for e in ocs.query(layer="Walls")["entities"]]
|
||||
ocs.select(handles=ids); ocs.run("ERASE")
|
||||
```
|
||||
|
||||
Every response has `"ok"`; failures carry `"error"`. `run` drives Open CAD
|
||||
Studio's **own** command system — no separate bindings to maintain — so its
|
||||
coverage grows with the app.
|
||||
|
||||
Interactive draw commands take their points as coordinate tokens; the tool is
|
||||
started, the points are fed, and it is terminated as if Enter were pressed:
|
||||
|
||||
```
|
||||
{"op":"run","cmd":"LINE 0,0 10,10 10,20"} → two Line segments
|
||||
{"op":"run","cmd":"CIRCLE 5,5 3"} → centre 5,5 radius 3
|
||||
```
|
||||
|
||||
Coordinates are `x,y` or `x,y,z`; `@dx,dy` is relative to the previous point.
|
||||
Inline-argument commands (`PDMODE 3`, `LAYER Walls`) are passed through as-is.
|
||||
|
||||
> Coverage is growing: commands whose options are typed keywords or coordinates
|
||||
> work; ones that still rely on on-screen picking (object selection by clicking)
|
||||
> are being wired next.
|
||||
|
||||
## Python client
|
||||
|
||||
[`ocs.py`](ocs.py) is a ~100-line client — nothing to compile:
|
||||
The legacy client is unchanged:
|
||||
|
||||
```python
|
||||
from ocs import Ocs
|
||||
|
||||
with Ocs(binary="OpenCADStudio") as ocs: # spawns `--serve`
|
||||
ocs.open("plan.dwg")
|
||||
ocs.run("LAYER Walls")
|
||||
print(ocs.entities())
|
||||
ocs.save("plan_out.dwg")
|
||||
with Ocs(binary="OpenCADStudio") as app:
|
||||
app.new()
|
||||
app.run("LINE 0,0 100,0")
|
||||
```
|
||||
|
||||
Any language can speak the same protocol over a subprocess pipe.
|
||||
For direct live diagnostics:
|
||||
|
||||
```sh
|
||||
python3 docs/automation/live.py --launch
|
||||
python3 docs/automation/live.py '{"op":"state"}'
|
||||
```
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd)
|
||||
DEST=${XDG_DATA_HOME:-"$HOME/.local/share"}/ocs-control
|
||||
mkdir -p "$DEST"
|
||||
cargo build --manifest-path "$ROOT/Cargo.toml" --release
|
||||
cp "$ROOT/target/release/OpenCADStudio" "$DEST/OpenCADStudio.new"
|
||||
chmod 755 "$DEST/OpenCADStudio.new"
|
||||
mv -f "$DEST/OpenCADStudio.new" "$DEST/OpenCADStudio"
|
||||
cp "$ROOT/docs/automation/live.py" "$DEST/live.py"
|
||||
cp "$ROOT/docs/automation/mcp_server.py" "$DEST/mcp_server.py"
|
||||
uv venv "$DEST/venv"
|
||||
uv pip install --python "$DEST/venv/bin/python" -r "$ROOT/docs/automation/requirements-mcp.txt"
|
||||
SKILLS=${CODEX_HOME:-"$HOME/.codex"}/skills
|
||||
mkdir -p "$SKILLS/opencadstudio-control/agents"
|
||||
cp "$ROOT/integrations/codex/opencadstudio-control/SKILL.md" "$SKILLS/opencadstudio-control/SKILL.md"
|
||||
cp "$ROOT/integrations/codex/opencadstudio-control/agents/openai.yaml" "$SKILLS/opencadstudio-control/agents/openai.yaml"
|
||||
codex mcp remove opencadstudio >/dev/null 2>&1 || true
|
||||
codex mcp add opencadstudio -- "$DEST/venv/bin/python" "$DEST/mcp_server.py"
|
||||
printf 'Installed OpenCADStudio and its persistent Codex MCP connection.\n'
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Persistent local GUI client; no CAD implementation lives in this adapter."""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
READ_OPS = {"state", "hello", "query", "entities", "layers", "header", "properties", "measure", "history", "commands", "events", "operation"}
|
||||
|
||||
|
||||
def discovery_dir() -> Path:
|
||||
if sys.platform == "win32":
|
||||
base = Path(os.environ["APPDATA"])
|
||||
elif sys.platform == "darwin":
|
||||
base = Path.home() / "Library/Application Support"
|
||||
else:
|
||||
base = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config"))
|
||||
return base / "OpenCADStudio/automation"
|
||||
|
||||
|
||||
def exchange(descriptor: dict, request: dict, timeout: float = 15) -> dict:
|
||||
payload = {**request, "token": descriptor["token"], "session_id": descriptor["session_id"], "protocol": 1}
|
||||
wire = (json.dumps(payload, ensure_ascii=False) + "\n").encode()
|
||||
if len(wire) > 1_048_576:
|
||||
raise ValueError("Request exceeds 1 MiB")
|
||||
with socket.create_connection(("127.0.0.1", descriptor["port"]), timeout=timeout) as sock:
|
||||
sock.sendall(wire)
|
||||
with sock.makefile("rb") as stream:
|
||||
line = stream.readline(16 * 1024 * 1024 + 1)
|
||||
if not line or len(line) > 16 * 1024 * 1024:
|
||||
raise ConnectionError("No valid OCS response; query request_id before retrying a mutation")
|
||||
return json.loads(line)
|
||||
|
||||
|
||||
def sessions() -> list[dict]:
|
||||
found = []
|
||||
for path in sorted(discovery_dir().glob("*.json")):
|
||||
descriptor = None
|
||||
try:
|
||||
if os.name == "posix" and (path.stat().st_uid != os.getuid() or path.stat().st_mode & 0o077):
|
||||
continue
|
||||
descriptor = json.loads(path.read_text())
|
||||
state = exchange(descriptor, {"op": "hello"}, timeout=1)
|
||||
if state.get("ok") and state.get("session_id") == descriptor["session_id"]:
|
||||
found.append({"descriptor": descriptor, "state": state})
|
||||
except (OSError, ValueError, KeyError):
|
||||
# A crashed process cannot remove its descriptor. Only reap a file
|
||||
# whose recorded PID is certainly gone; a busy live session keeps
|
||||
# its discovery record for the next call.
|
||||
try:
|
||||
os.kill(int(descriptor["pid"]), 0)
|
||||
except (ProcessLookupError, TypeError, KeyError):
|
||||
try:
|
||||
path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
continue
|
||||
return found
|
||||
|
||||
|
||||
class LiveOcs:
|
||||
def __init__(self, session_id: str | None = None):
|
||||
available = sessions()
|
||||
if session_id:
|
||||
available = [s for s in available if s["state"]["session_id"] == session_id]
|
||||
if len(available) != 1:
|
||||
raise ValueError("Choose session_id from sessions; found " + str(len(available)))
|
||||
self.descriptor = available[0]["descriptor"]
|
||||
self.state = available[0]["state"]
|
||||
self.client_id = uuid.uuid4().hex
|
||||
|
||||
def request(self, request: dict, wait_seconds: float = 30) -> dict:
|
||||
req = dict(request)
|
||||
if req.get("op") not in READ_OPS:
|
||||
req.setdefault("request_id", uuid.uuid4().hex)
|
||||
req.setdefault("client_id", self.client_id)
|
||||
req.setdefault("document_id", self.state["document_id"])
|
||||
req.setdefault("revision", self.state["revision"])
|
||||
if req.get("op") in {"input", "property", "run", "action", "save", "undo", "redo"}:
|
||||
req.setdefault("selection", self.state.get("selection", []))
|
||||
response = exchange(self.descriptor, req)
|
||||
deadline = time.monotonic() + min(max(wait_seconds, 0), 60)
|
||||
while response.get("status") in ("accepted", "running") and time.monotonic() < deadline:
|
||||
time.sleep(0.05)
|
||||
response = exchange(self.descriptor, {"op": "operation", "request_id": req["request_id"]})
|
||||
if "state" in response:
|
||||
self.state = response["state"]
|
||||
elif req.get("op") in ("hello", "state") and response.get("ok"):
|
||||
self.state = response
|
||||
return response
|
||||
|
||||
|
||||
def launch(binary: str | None = None) -> list[dict]:
|
||||
existing = sessions()
|
||||
if existing:
|
||||
return existing
|
||||
executable = binary or os.environ.get("OCS_BINARY", str(Path.home() / ".local/share/ocs-control/OpenCADStudio"))
|
||||
log_dir = Path.home() / ".local/share/ocs-control"
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
with (log_dir / "gui.log").open("ab") as log:
|
||||
proc = subprocess.Popen([executable, "--new-instance"], stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
|
||||
deadline = time.monotonic() + 30
|
||||
while time.monotonic() < deadline:
|
||||
if proc.poll() is not None:
|
||||
raise RuntimeError(f"OCS exited ({proc.returncode}); inspect {log_dir / 'gui.log'}")
|
||||
available = sessions()
|
||||
if available:
|
||||
return available
|
||||
time.sleep(0.2)
|
||||
raise TimeoutError("OCS is still starting; call sessions again")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("request", nargs="?", help="JSON request; omit to list sessions")
|
||||
parser.add_argument("--session")
|
||||
parser.add_argument("--launch", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.launch:
|
||||
launch()
|
||||
if args.request:
|
||||
print(json.dumps(LiveOcs(args.session).request(json.loads(args.request)), ensure_ascii=False))
|
||||
else:
|
||||
print(json.dumps([s["state"] for s in sessions()], ensure_ascii=False))
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Local stdio MCP adapter for a running OpenCADStudio GUI."""
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
from typing import Any
|
||||
from mcp.server.fastmcp import FastMCP, Image
|
||||
from live import LiveOcs, READ_OPS, launch, sessions
|
||||
|
||||
mcp = FastMCP("OpenCADStudio", instructions="Use ocs_sessions, then ocs_read(state/commands) before editing. All changes target the actual GUI document. Preserve session_id, document_id and revisions. waiting_input means the command or dialog still needs input. Poll running operations; never replay a mutation with a new request_id after a timeout. Geometry is handled by OCS and its kernel.")
|
||||
clients: dict[str, LiveOcs] = {}
|
||||
|
||||
|
||||
def client(session_id: str) -> LiveOcs:
|
||||
if session_id not in clients:
|
||||
clients[session_id] = LiveOcs(session_id)
|
||||
return clients[session_id]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ocs_sessions(launch_if_none: bool = True) -> list[dict[str, Any]]:
|
||||
"""List real OCS GUI sessions and documents. Launch installed OCS if none is running."""
|
||||
available = launch() if launch_if_none else sessions()
|
||||
return [s["state"] for s in available]
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ocs_read(session_id: str, op: str = "state", parameters: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Read state, commands/actions, query (type/layer/offset/limit), entities, layers, header, properties, measure(handles), history, events(after), operation(request_id)."""
|
||||
if op not in READ_OPS:
|
||||
raise ValueError("Use ocs_execute for mutations")
|
||||
return client(session_id).request({**(parameters or {}), "op": op})
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ocs_execute(session_id: str, request: dict[str, Any], wait_seconds: float = 30) -> dict[str, Any]:
|
||||
"""Execute one semantic action. op: new/open(path)/activate(document_id)/start(cmd)/run(cmd)/input(kind,text or point)/select(handles)/property(field,value)/action(name)/save(path)/undo/redo/cancel. Input kinds: text, token, point(space wcs/ucs/relative), entity or structure(handle,point), selection, enter. Supply document_id and revision from state. request_id is the idempotency key. Accepted/running is not completion. Query operation after a timeout."""
|
||||
return client(session_id).request(request, wait_seconds)
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def ocs_capture(session_id: str) -> Image:
|
||||
"""Capture the actual current OCS window as PNG for visual verification."""
|
||||
with tempfile.TemporaryDirectory(prefix="ocs-capture-") as directory:
|
||||
path = Path(directory) / "window.png"
|
||||
result = client(session_id).request({"op": "capture", "path": str(path)})
|
||||
if not result.get("ok") or result.get("status") != "completed":
|
||||
raise RuntimeError(str(result))
|
||||
return Image(data=path.read_bytes(), format="png")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run(transport="stdio")
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Protocol smoke test for the bundled MCP adapter."""
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
async def main() -> None:
|
||||
server = Path(__file__).with_name("mcp_server.py")
|
||||
params = StdioServerParameters(command=sys.executable, args=[str(server)])
|
||||
async with stdio_client(params) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
await session.initialize()
|
||||
tools = await session.list_tools()
|
||||
names = {tool.name for tool in tools.tools}
|
||||
assert names == {"ocs_sessions", "ocs_read", "ocs_execute", "ocs_capture"}, names
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||
@@ -0,0 +1 @@
|
||||
mcp==1.29.1
|
||||
Reference in new issue
Block a user