feat(automation): headless JSON server (--serve) + Python client

Add an external automation API (issue #29 / #100 track 2): `OpenCADStudio
--serve` runs without a GUI and is driven over a line-based JSON protocol
on stdin/stdout — open / new / run / entities / save. State persists
across requests so a script or AI agent can act, observe, and act again.

`run` drives the app's existing command system rather than a separate
binding, so coverage grows with the app; synchronous commands apply now,
pick-based interactive ones come once coordinate feeding is wired. Ships
a ~100-line example ocs.py client over the same protocol — no FFI to
maintain. Headless works because the app already constructs and dispatches
GUI-less in tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-06-17 21:05:12 +03:00
commit 7f10b69e3c
6 changed files with 341 additions and 0 deletions

46
docs/automation/README.md Normal file
View file

@ -0,0 +1,46 @@
# Headless automation API
Open CAD Studio can run without a GUI and be driven over a line-based JSON
protocol — for scripts, batch jobs, or AI agents.
```sh
OpenCADStudio --serve
```
It reads one JSON request per line on **stdin** and writes one JSON response per
line on **stdout**. The active document persists across requests, so a caller
can act → observe → act.
## Protocol
| 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":"save","path":"out.dwg"}` | `{"ok":true,"saved":"out.dwg"}` (path optional once opened/saved) |
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.
> **Status (first increment):** `run` applies synchronous commands (system
> variables, layer ops, …). Pick-based interactive commands (drawing by clicking
> points) need coordinate feeding and are not wired headless yet.
## Python client
[`ocs.py`](ocs.py) is a ~100-line client — nothing to compile:
```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")
```
Any language can speak the same protocol over a subprocess pipe.

Binary file not shown.

90
docs/automation/ocs.py Normal file
View file

@ -0,0 +1,90 @@
"""Thin Python client for the Open CAD Studio headless automation server.
Launches `OpenCADStudio --serve` and talks to it over a line-based JSON protocol
(one request object per line on stdin, one response per line on stdout). There
is nothing to compile or maintain on the Python side every method is one JSON
message; the real work is Open CAD Studio's own command system.
from ocs import Ocs
with Ocs(binary="OpenCADStudio") as ocs:
ocs.open("plan.dwg")
ocs.run("LAYER Walls")
print(ocs.entities()) # {"total": 42, "by_type": {...}}
ocs.save("plan_out.dwg")
Each call returns the parsed response dict and raises `OcsError` on `ok: false`.
"""
from __future__ import annotations
import json
import subprocess
from typing import Any, Optional
class OcsError(RuntimeError):
"""Raised when the server replies with `{"ok": false, ...}`."""
class Ocs:
def __init__(self, binary: str = "OpenCADStudio") -> None:
self.proc = subprocess.Popen(
[binary, "--serve"],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
text=True,
bufsize=1,
)
self._read() # the {"ready": true} greeting
# ── protocol ────────────────────────────────────────────────────────────
def _read(self) -> dict[str, Any]:
line = self.proc.stdout.readline()
if not line:
raise OcsError("server closed the connection")
return json.loads(line)
def _send(self, **req: Any) -> dict[str, Any]:
self.proc.stdin.write(json.dumps(req) + "\n")
self.proc.stdin.flush()
resp = self._read()
if not resp.get("ok", False):
raise OcsError(resp.get("error", "unknown error"))
return resp
# ── operations ──────────────────────────────────────────────────────────
def new(self) -> dict[str, Any]:
"""Start an empty document."""
return self._send(op="new")
def open(self, path: str) -> dict[str, Any]:
"""Load a DWG/DXF drawing."""
return self._send(op="open", path=path)
def run(self, cmd: str) -> dict[str, Any]:
"""Run a command through Open CAD Studio's command system."""
return self._send(op="run", cmd=cmd)
def entities(self) -> dict[str, Any]:
"""Total entity count and a breakdown by type."""
return self._send(op="entities")
def save(self, path: Optional[str] = None) -> dict[str, Any]:
"""Write the document (defaults to the opened/last-saved path)."""
return self._send(op="save", path=path)
# ── lifecycle ───────────────────────────────────────────────────────────
def close(self) -> None:
if self.proc.stdin:
self.proc.stdin.close()
try:
self.proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self.proc.kill()
def __enter__(self) -> "Ocs":
return self
def __exit__(self, *_exc: object) -> None:
self.close()

194
src/app/automation.rs Normal file
View file

@ -0,0 +1,194 @@
//! Headless automation server (`OpenCADStudio --serve`).
//!
//! Drives the app without a GUI over a line-based JSON protocol: one request
//! object per line on stdin, one response object per line on stdout. State (the
//! active document) persists across requests, so an external process — a script
//! or an AI agent — can act, observe, and act again.
//!
//! Operations:
//! - `{"op":"new"}` — start an empty document
//! - `{"op":"open","path":"file.dwg"}` — load a drawing
//! - `{"op":"run","cmd":"LAYER Walls"}` — run a command (the same dispatcher
//! the GUI command line uses)
//! - `{"op":"entities"}` — summary count by entity type
//! - `{"op":"save","path":"out.dwg"}` — write the document (path optional
//! once opened/saved)
use std::io::{BufRead, Write};
use std::path::PathBuf;
use serde_json::{json, Value};
use super::OpenCADStudio;
/// Run the headless JSON server until stdin closes.
pub fn serve() {
let mut app = OpenCADStudio::new();
let stdin = std::io::stdin();
let stdout = std::io::stdout();
emit(
&stdout,
json!({"ok": true, "ready": true, "version": env!("CARGO_PKG_VERSION")}),
);
for line in stdin.lock().lines() {
let Ok(line) = line else { break };
let line = line.trim();
if line.is_empty() {
continue;
}
let resp = app.automation_op(line);
emit(&stdout, resp);
}
}
fn emit(stdout: &std::io::Stdout, value: Value) {
let mut o = stdout.lock();
let _ = writeln!(o, "{value}");
let _ = o.flush();
}
fn err(msg: impl std::fmt::Display) -> Value {
json!({ "ok": false, "error": msg.to_string() })
}
impl OpenCADStudio {
/// Handle one JSON request line and return the JSON response.
pub(crate) fn automation_op(&mut self, line: &str) -> Value {
let req: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(e) => return err(format!("invalid JSON: {e}")),
};
match req["op"].as_str().unwrap_or("") {
"new" => {
let i = self.active_tab;
self.tabs[i].scene.document = acadrust::CadDocument::new();
self.tabs[i].current_path = None;
self.tabs[i].scene.bump_geometry();
self.entity_summary()
}
"open" => {
let Some(path) = req["path"].as_str() else {
return err("open: missing \"path\"");
};
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) => return err(format!("open: {e}")),
};
let name = PathBuf::from(path)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string());
match crate::io::load_bytes(&name, bytes) {
Ok(doc) => {
let i = self.active_tab;
self.tabs[i].scene.document = doc;
self.tabs[i].current_path = Some(PathBuf::from(path));
self.tabs[i].scene.bump_geometry();
self.entity_summary()
}
Err(e) => err(format!("open: {e}")),
}
}
"run" => {
let cmd = req["cmd"].as_str().unwrap_or("").to_string();
if cmd.is_empty() {
return err("run: missing \"cmd\"");
}
let i = self.active_tab;
let before = self.tabs[i].scene.document.entities().count();
// The returned Task drives GUI follow-up; synchronous commands
// (system variables, layer ops, …) have already applied. Pick-
// based interactive commands need coordinate feeding — not yet
// wired headless.
let _ = self.dispatch_command(&cmd);
let after = self.tabs[i].scene.document.entities().count();
json!({
"ok": true,
"cmd": cmd,
"entities": after,
"added": after as i64 - before as i64,
})
}
"entities" => self.entity_summary(),
"save" => {
let i = self.active_tab;
let path = req["path"]
.as_str()
.map(PathBuf::from)
.or_else(|| self.tabs[i].current_path.clone());
let Some(path) = path else {
return err("save: no \"path\" and the document has none");
};
match crate::io::save(&self.tabs[i].scene.document, &path) {
Ok(()) => {
self.tabs[i].current_path = Some(path.clone());
json!({ "ok": true, "saved": path.to_string_lossy() })
}
Err(e) => err(format!("save: {e}")),
}
}
"" => err("missing \"op\""),
other => err(format!("unknown op: {other}")),
}
}
/// Count of entities in the active document, total and by type.
fn entity_summary(&self) -> Value {
let i = self.active_tab;
let mut by_type: std::collections::BTreeMap<String, u64> = Default::default();
let mut total = 0u64;
for e in self.tabs[i].scene.document.entities() {
*by_type
.entry(crate::entities::names::ui_name(e).to_string())
.or_default() += 1;
total += 1;
}
json!({ "ok": true, "total": total, "by_type": by_type })
}
}
#[cfg(test)]
mod tests {
use crate::app::OpenCADStudio;
#[test]
fn automation_ops_round_trip() {
let mut app = OpenCADStudio::new_for_test();
let r = app.automation_op(r#"{"op":"new"}"#);
assert_eq!(r["ok"], true);
assert_eq!(r["total"], 0);
// A synchronous command runs through the real dispatcher.
let r = app.automation_op(r#"{"op":"run","cmd":"PDMODE 3"}"#);
assert_eq!(r["ok"], true);
assert_eq!(r["cmd"], "PDMODE 3");
let r = app.automation_op(r#"{"op":"entities"}"#);
assert_eq!(r["ok"], true);
// Errors are reported, never panics.
assert_eq!(app.automation_op(r#"{"op":"bogus"}"#)["ok"], false);
assert_eq!(app.automation_op("not json")["ok"], false);
assert_eq!(app.automation_op(r#"{"op":"run"}"#)["ok"], false);
}
#[test]
fn save_then_open_round_trips() {
let mut app = OpenCADStudio::new_for_test();
let path = std::env::temp_dir().join("ocs_automation_test.dxf");
let p = path.to_string_lossy();
app.automation_op(r#"{"op":"new"}"#);
assert_eq!(
app.automation_op(&format!(r#"{{"op":"save","path":"{p}"}}"#))["ok"],
true
);
assert_eq!(
app.automation_op(&format!(r#"{{"op":"open","path":"{p}"}}"#))["ok"],
true
);
let _ = std::fs::remove_file(&path);
}
}

View file

@ -1,3 +1,7 @@
#[cfg(not(target_arch = "wasm32"))]
mod automation;
#[cfg(not(target_arch = "wasm32"))]
pub use automation::serve;
mod cmd_result; mod cmd_result;
mod commands; mod commands;
mod document; mod document;

View file

@ -39,6 +39,13 @@ fn main() -> iced::Result {
} }
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
{ {
// Headless automation server — drive the app over JSON on stdin/stdout
// with no GUI (issue #29). `--serve` is the opt-in; everything else
// launches the editor.
if std::env::args().skip(1).any(|a| a == "--serve") {
app::serve();
return Ok(());
}
app::run() app::run()
} }
} }