docs(automation): make native.md match the code

The page merged from #1401 described --serve and --mcp as one surface.
Rewritten against src/app/automation.rs, src/app/control/mod.rs and
src/mcp.rs: the --serve readiness banner and --port mode, the two request
styles on that channel and how each treats a waiting command
(command_busy versus replacement), measure being available headlessly
while capture is not, --export writing DWG for every extension but .dxf,
startup dialogs belonging to the editor behind --mcp only, MCP sessions
being shared rather than one editor per connection, the MTEXT editor's
own actions, and the legacy run reply fields (blocked_by, unconsumed)
that only that path returns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Hakan SevenandClaude Fable 5.1 committed 2026-09-20 20:30:46 +03:00
1 parent dece694478
commit 75e7f825cd
1 file changed
+89 -65
+89 -65
View File
@@ -1,32 +1,82 @@
# Driving the native builds
Every native build exposes the same control core through two entry points: the MCP
server described in the [README](README.md) (`OpenCADStudio --mcp`), and a headless
JSON-line channel meant for scripts and CI (`OpenCADStudio --serve`). The operations,
request envelope and replies are the same; this page covers what is specific to the
native side. The browser entry is described in [web.md](web.md).
Every native build has three entry points besides the editor window: the MCP
server described in the [README](README.md) (`OpenCADStudio --mcp`), a headless
JSON-line channel for scripts and CI (`OpenCADStudio --serve`), and a one-shot
converter (`OpenCADStudio --export IN OUT`). The browser entry is described in
[web.md](web.md).
## Choosing a channel
| | `--mcp` | `--serve` |
|---|---|---|
| Transport | MCP over stdio | one JSON request per line on stdin, one reply per line on stdout |
| Editor | starts the desktop editor and exposes the live document | no window; works on its own document |
| Needs a display | yes — on a headless machine `xvfb-run` works, on software rendering the viewport may show the GPU warning dialog | no |
| `measure`, `ocs_capture`, `set_properties`, interactive steps | available | `measure` and `capture` are not; the remaining operations are |
| Transport | MCP (JSON-RPC over stdio) with the four tools of the README | one JSON request per line on stdin, one reply per line on stdout; `--port N` listens on `127.0.0.1:N` instead |
| Editor | attaches to a running desktop editor, or starts one when there is none (`ocs_sessions`, `launch_if_none`) | no window; a document session of its own that lives as long as the process |
| Needs a display | yes (`xvfb-run` works on a headless machine; software rendering may raise the GPU warning dialog) | no |
| Needs a writable `HOME` / `XDG_CONFIG_HOME` | yes — the session descriptors live in the user configuration directory; without one `ocs_sessions` answers `No user configuration directory` | no |
| Startup dialogs | those of the editor (below) | none |
| `capture` | `ocs_capture` | not available (`gui_required`): there is no window to capture |
| Good for | an MCP client driving a real editor | batch jobs, CI, agents without a display |
`OpenCADStudio --export IN OUT` converts a drawing in one shot, without any session. It
accepts `.dwg` and `.dxf` targets only.
## `--serve`
## Requirements
The first line written is a readiness banner, before any request is read:
The process needs a writable `HOME` (or `XDG_CONFIG_HOME`) — without one, `ocs_sessions`
answers `No user configuration directory` and nothing else works.
```json
{"ok":true,"ready":true,"version":"2026.38"}
```
## Startup dialogs
Over `--port N` the banner opens every connection; the listener serves one
client at a time and the document session persists across reconnects.
A fresh profile opens two dialogs before any drawing exists, and both of them block
Two request styles share the channel, told apart by the `protocol` field:
| Style | Shape | Operations |
|---|---|---|
| protocol 1 | `{"protocol":1,"request_id":"…","document_id":N,"op":"…"}` — the same requests and replies as `ocs_execute` / `ocs_read`, described in the README | `new`, `open`, `save`, `activate`, `select`, `run`, `start`, `input`, `cancel`, `stop`, `action`, `property`, `set_properties`, `embed_image`, `undo`, `redo`; reads `state`, `capabilities`, `query`, `entities`, `records`, `record_schema`, `layers`, `header`, `properties`, `measure`, `history`, `commands`, `events`, `operation` |
| legacy | `{"op":"…"}` without `protocol` | `new`, `open`, `run`, `entities`, `query`, `records`, `record_schema`, `capabilities`, `layers`, `header`, `select`, `save`, `undo`, `redo` |
The two styles differ in how a waiting command is treated. A protocol-1 `run`
or `start` while a command is still active is refused with `command_busy`:
continue it with `input` or drop it with `cancel`. A legacy `run` line ends the
waiting command and starts the new one.
### The legacy `run` line
`{"op":"run","cmd":"LINE 0,0 10,10"}` feeds the tokens after the command name
to the prompts in order and then presses Enter. The reply is
```json
{"ok":true,"cmd":"CIRCLE 0,0 5 9","status":"completed","blocked_by":null,"entities":1,"added":1,"unconsumed":["9"]}
```
| Field | Meaning |
|---|---|
| `status` | `completed`, or `waiting_input` when the line left something open |
| `blocked_by` | what *this line* left open: `"command"`, `"text_editor"`, `"mtext_editor"`, `"modal:<Kind>"`, or `null`. A surface that was already open when the line started is not attributed to it |
| `entities`, `added` | entity count after the line, and the difference to before |
| `unconsumed` | tokens no prompt asked for, in order (`["9"]` above: the radius step ended the command); `[]` when everything was consumed |
`waiting_input` means input is still required, not that nothing happened:
`SPLINE` and `ARC` keep accepting input after the entity exists, and
`zoom_extents` reports it after the view has already moved.
`TEXT 0,0 5 0 hello world` creates one `Text` entity holding `hello world`: the
tail of the line is typed into the in-place editor, committed, and the command
is closed so it does not stay armed for a second line. A `TEXT` line without
content answers `waiting_input` with `blocked_by: "text_editor"`.
Known gap (#1390): `MTEXT`, `HATCH` and `POLYGON` answer `completed` with
`added: 0` on this path — they need interactive surfaces (the rich-text editor,
a pattern choice, a boundary pick) that a single line has no tokens to fill.
## `--mcp`
The MCP server is a client of the desktop editor: `ocs_sessions` lists the live
editor sessions found in the configuration directory and starts one when none
is running, so several MCP connections can share one editor and its state.
A fresh profile opens two dialogs before any drawing exists, and both block
document creation:
| Step | `ocs_sessions` reports |
@@ -35,73 +85,47 @@ document creation:
| after `close_modal` | `modal: "DonationPrompt"` |
| after a second `close_modal` | `modal: null` |
While a dialog is open, `{"op":"new"}` answers `ok` but no tab is created, so there is
no `document_id` to work with. Close the dialogs first:
While a dialog is open, `{"op":"new"}` answers `ok` but no tab is created.
Close the dialogs first:
```python
while (info := sessions())["modal"]:
execute({"op": "action", "name": "close_modal", "document_id": info["document_id"]})
```
The donation prompt appears once per application version per profile, so an automated
profile meets it once per version rather than on every run. See
[web.md](web.md) for the same pattern on the browser side.
The donation prompt appears once per application version per profile, so an
automated profile meets it once per version rather than on every run.
## Targeting a document
`new` makes the new drawing the active document. Requests carry `document_id`;
take it from the reply's state and pass it on the following requests, as the
README describes.
Reads and mutations target the active tab. `new` creates a document but does not move
the active tab, so pass the `document_id` from the reply explicitly — without it, reads
answer empty and it looks like nothing was created. `activate` switches the active tab.
A client connection to `--mcp` starts a new editor instance, so session state does not
carry over between connections.
## `--export`
## Feeding a whole command line
`run` takes one complete command line, including the answers to every prompt, and then
terminates the command as if Enter were pressed. A prompt cannot be continued on a
following line — each line is parsed as a fresh command line, so a command left waiting
is replaced by the next one (send `{"op":"cancel"}` if you want to drop it explicitly).
`waiting_input` means input is still required — not that nothing happened. `SPLINE` and
`ARC`, for example, keep accepting further input even after the entity exists, and
`zoom_extents` reports it while the view has already changed. Send `{"op":"cancel"}` to
close the command when the line is done with it.
Two reply fields describe what happened to the line:
| Field | Meaning |
|---|---|
| `unconsumed` | Tokens that no prompt ever asked for, in order. `CIRCLE 0,0 5 9` reports `["9"]`, because the radius step ends the command; a fully consumed line reports `[]`. |
| `blocked_by` | What *this line* left open: `"command"`, `"text_editor"`, `"mtext_editor"`, `"modal:<Kind>"`, or `null`. Set whenever `status` is `waiting_input`. |
`TEXT 0,0 5 0 hello world` therefore creates one `Text` entity that holds
`hello world`, reports `completed` with `added: 1`, and leaves nothing unconsumed. The
content step runs in the in-place editor, which the feeder commits and then closes so
that the command does not stay armed for a second line.
`OpenCADStudio --export IN OUT` loads `IN` and writes `OUT` without a window or
a session. The output format follows the extension: `.dxf` writes DXF, any other
extension writes DWG at the document's own version. The exit code is 0 on
success and 1 when the input cannot be read or the output cannot be written.
## Interactive steps
For commands whose answers cannot be written on one line, use `start` and then `input`.
`start` replies with `state.command`, whose `accepts`, `options`, `prompt` and
For commands whose answers cannot be written on one line, use `start` and then
`input`. `start` replies with `state.command`, whose `accepts`, `options` and
`input_example` say what the current step wants:
| Step | Request | Note |
|---|---|---|
| a point | `{"op":"input","kind":"point","point":[x,y,0],"space":"wcs"}` | the point is an array; separate `x`/`y` fields are rejected |
| a point | `{"op":"input","kind":"point","point":[x,y,0],"space":"wcs"}` | the point is an array; `space` is `wcs` (default), `ucs` or `relative` |
| a typed value (height, rotation) | `{"op":"input","kind":"text","text":"5"}` | digits go through `text`, not `token`; `{"op":"input","kind":"enter"}` accepts the default |
| a keyword (`J`, `ST`, `C`) | `{"op":"input","kind":"token","text":"C"}` | the payload field is named `text` |
| the text of `TEXT`/`MTEXT` | `{"op":"action","name":"text_input","value":"hello"}` then `{"op":"action","name":"text_commit"}` | the command has already ended at that point and the editor owns the input |
| an object | `{"op":"input","kind":"entity","handle":"HANDLE","point":[x,y,0]}` | `kind: "structure"` for a sub-entity pick, `kind: "selection"` to hand over the current selection |
| the content of `TEXT` | `{"op":"action","name":"text_input","value":"hello"}` then `{"op":"action","name":"text_commit"}` | the command has already ended; the in-place editor owns the input |
| the content of `MTEXT` | `{"op":"action","name":"mtext_insert","value":"hello"}` then `{"op":"action","name":"mtext_commit"}` | the rich-text editor has its own actions; the `TEXT` ones answer `editor_closed` |
## Native-only operations
| Operation | Device |
|---|---|
| `measure` | `ocs_read` with `op: "measure"` and `parameters.handles` returns kernel-computed length, area, bounds and mass properties. |
| `ocs_capture` | Returns the viewport or the whole window. Either an inline base64 image, or a PNG written under the temporary directory with the reply carrying its `path`. |
| `set_properties` | Mutates record properties. It requires `collection`, and colours are the serialised enum — `{"Index": 1}` or `"ByLayer"`; a colour name such as `"Red"` is rejected with `invalid_value`. |
## Known gaps
On the `--serve` path, `MTEXT`, `HATCH` and `POLYGON` report `completed` with
`added: 0`: they need interactive surfaces (a pattern choice, a boundary selection) that
a single command line has no tokens to fill. `TEXT` no longer belongs to that list.
| Operation | `--serve` (protocol 1) | `--mcp` |
|---|---|---|
| measure | `{"protocol":1,"op":"measure","handles":["2A"]}` — kernel-computed length, area, bounds and mass properties; no window needed | `ocs_read` with `op: "measure"` and `parameters.handles` |
| capture | `{"protocol":1,"op":"capture","path":"out.png"}` — needs the editor window, so it answers `gui_required` here | `ocs_capture` with `scope` (`viewport` or `window`) and `max_dimension` |
| `set_properties` | requires `collection`; colours are the serialised enum — `{"Index": 1}` or `"ByLayer"`; a colour name such as `"Red"` is rejected with `invalid_value` | same request through `ocs_execute` |