Files
OneCAD-Draft/docs/plugin-architecture.md
T
Felix RiestraandClaude Sonnet 5 bd49bd66d4 docs(plugin): fix inaccuracies and flag real gaps found building a plugin
Found while building an out-of-tree scripting plugin from scratch against
this doc and the actual ocs_plugin_api/host source. Two categories:

Fixed outright (docs/examples didn't match the real code):
- The worked CadModule example returned Vec<RibbonGroup> from
  ribbon_groups(); the real trait requires &[RibbonGroup] - the example
  wouldn't compile. Fixed with the OnceLock pattern the host's own
  benchmark code already uses.
- Reconciled a contradiction between "process isolation removes the need
  for a shared Rust ABI" and the exact-rustc_version requirement described
  a few paragraphs earlier in the same doc.
- The roadmap's "Next" list still had external automation as unstarted;
  --mcp/--serve are real and operational (src/cli.rs, src/mcp.rs).
- Added the missing plugin.toml to docs/plugin-template-v2 (its own
  DESIGN.md already claimed one could be installed).
- Documented that plugin-template's CI floats rust-toolchain@stable, which
  doesn't guarantee matching the host's exact rustc - added acadrust_source
  to both templates' plugin.toml plus the release.yml step that fills it
  from Cargo.lock's resolved acadrust source, alongside the existing
  rustc_version fill.

Flagging for your call, not fixed here (found the behavior, not sure it's
the intended one, prefer to ask than guess at a fix):
- acadrust_compatible()'s ABI gate only runs at all if plugin.toml declares
  acadrust_source - an undeclared field is silently treated as compatible,
  unlike rustc_version (undeclared = incompatible). The same shortcut
  appears in two places: ExternalPlugin::acadrust_compatible
  (src/plugin/external.rs) and the inline check inside fetch_release_info
  (src/plugin/marketplace.rs). If this is deliberate (e.g. back-compat with
  manifests from before the field existed), no action needed beyond a
  comment; if not, both call sites need the same fix.
- The command line still uppercases typed/pasted input even with the `>`
  literal-spaces prefix (Message::CommandInput, src/app/update/mod.rs) -
  `>` only stops a space from auto-submitting the line, it doesn't skip
  `s.to_uppercase()`. A plugin command needing a case-sensitive typed
  argument has no escape today short of automation.
- fetch_release_info (src/plugin/marketplace.rs) returns every release
  under a linked repo with no filter on the manifest's own id field (which
  it already parses to build ReleaseInfo) - a repo hosting more than one
  plugin under tag-namespaced releases surfaces all of them mixed
  together in one plugin's version list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 03:37:54 +02:00

527 lines
25 KiB
Markdown

# Open CAD Studio — Plugin Architecture
**Status:** Accepted
**Author:** Open CAD Studio contributors
**Date:** June 2026
This document is the **authoritative spec** for how add-on packages integrate
with Open CAD Studio. The model follows [QGIS](https://plugins.qgis.org/)-style
extensibility: a small metadata file, a single entry point, an optional separate
engine crate, and user-installable packages from a curated index.
> **Open CAD Studio ships no built-in plugins.** Every add-on is an **external
> dynamic library** (`cdylib`) the host loads at runtime from the user plugins
> folder. The host source only contains the generic plugin *runtime*
> (`src/plugin/`, `src/app/plugin_host.rs`) and the stable contract crate
> (`crates/ocs_plugin_api`). Add-ons live in their own repositories and
> **consume** that contract.
---
## Design goals
| Goal | Rationale |
|------|-----------|
| **One package, one entry point** | Manifest, ribbon tab and commands ship together in the plugin crate; no edits to the host. |
| **Stable contract** | Authors target the semver-versioned `ocs_plugin_api` crate, not `OpenCADStudio` internals. |
| **Out-of-tree by default** | A plugin is its own repo + crate; the host never recompiles to gain one. |
| **DWG round-trip** | Domain data lives on entities as XDATA, not in an opaque side database. |
| **Engine reuse** | A headless `std`-only engine crate can run in WASM/CLI without the CAD host. |
## Non-goals
- Signature verification (installing a plugin runs native code; the user trusts
the repos they install from). Process isolation limits the blast radius of a
buggy or malicious plugin, but it is not a security sandbox.
- Cross-toolchain binary compatibility — see [Compatibility](#compatibility--abi).
- Sandboxed scripting (Python/Lua); replacing the `acadrust` entity model.
---
## Three layers
```
┌────────────────────────────────────────────────────────────────────┐
│ Layer A — Host (OpenCADStudio) │
│ iced UI · Scene · Document · Undo · Command line │
│ Core ribbon tabs: Home, Model, View, … (NOT plugins) │
│ Generic plugin runtime: discovery, spawn, dispatch │
└───────────────────────────────┬────────────────────────────────────┘
│ IPC over local socket (ocs_plugin_api)
┌───────────────────────────────▼────────────────────────────────────┐
│ Layer B — Plugin process (external repo, cdylib) │
│ host spawns itself in runner mode · Cargo.toml · plugin.toml │
│ PluginManifest · CadModule ribbon · BuiltinPlugin · export_plugin! │
└───────────────────────────────┬────────────────────────────────────┘
│ pure Rust API
┌───────────────────────────────▼────────────────────────────────────┐
│ Layer C — Domain engine crate (optional) │
│ hydraulics / COGO / … — `std` only, no iced/acadrust │
└────────────────────────────────────────────────────────────────────┘
```
| Layer | Lives in | May depend on |
|-------|----------|---------------|
| **A — Host** | this repo: `src/`, `crates/ocs_plugin_api` runtime | everything |
| **B — Plugin** | a separate repo (cdylib), spawned by the host in runner mode | `ocs_plugin_api` + optional engine |
| **C — Engine** | the plugin's own crate or crates.io | `std` only (WASM/CLI-capable) |
**Hard rules**
1. The host (`src/plugin/`) imports no plugin code — it only knows the contract.
2. Engine crates import neither `iced`, `acadrust`, nor `OpenCADStudio`.
3. A plugin never edits host source; it runs entirely from its own crate.
---
## The contract crate — `ocs_plugin_api`
[`crates/ocs_plugin_api`](../crates/ocs_plugin_api) is the semver-versioned API a
plugin compiles against. Two tiers:
- **Dependency-free core** (default): `PluginManifest` / `ApiVersion` and the
ribbon vocabulary — `CadModule`, `ToolDef`, `RibbonGroup`, `RibbonItem`,
`IconKind`, `ModuleEvent`, `StyleKey`. Engine crates and tooling depend on this
cheaply.
- **`host` feature** (pulls `acadrust`): the runtime surface — the `HostApi`
trait, the `BuiltinPlugin` entry-point trait, the `export_plugin!` macro, and
the out-of-process plugin runtime (`PluginProcess`, `runner`).
A plugin enables the `host` feature.
### Build-time ABI metadata
For API v4 and later the host records two pieces of build-time metadata that
affect whether a prebuilt plugin can safely be loaded:
- `acadrust_source` — the exact git source of the `acadrust` crate the host was
built against.
- `rustc_version` — the output of `rustc --version` for the compiler that built
the host.
A plugin's `plugin.toml` declares the same values under `[opencad]`. For API v4
and later, `rustc_version` is required and the host refuses to load the plugin
unless it matches exactly — omitting it is treated as a mismatch, not skipped.
This turns the current silent runner crash into a one-line diagnosis such as:
```
Plugin built with rustc 1.96.0, host requires 1.98.0 - rebuild required
```
**`acadrust_source` is checked differently, and this matters:** unlike
`rustc_version`, the check only runs if the plugin's `plugin.toml` declares
`acadrust_source` at all — the same `if !acadrust_declared { return true; }`
(or equivalent inline match) appears in **two** places,
`ExternalPlugin::acadrust_compatible` in `src/plugin/external.rs` (used when
loading an installed plugin) and the inline check inside
`fetch_release_info` in `src/plugin/marketplace.rs` (used when listing
releases in the Plugin Manager) — a manifest that omits the key entirely is treated
as compatible, not rejected. In practice this means the ABI gate is
opt-in per plugin rather than enforced across the board: a `plugin.toml` copied
from an older template that never added the field sails through unchecked.
**Always declare `acadrust_source`** (see the worked example below) so the gate
actually applies to your plugin; don't rely on the host to catch its absence.
### `PluginManifest`
```rust
pub struct PluginManifest {
pub id: &'static str, // reverse-DNS: "opencad.example"
pub name: &'static str,
pub version: &'static str,
pub description: &'static str,
pub api_version: ApiVersion, // host ABI major; must match the host
pub ribbon_order: i32, // sort key among add-on tabs
pub xdata_apps: &'static [&'static str],
pub command_prefixes: &'static [&'static str],
}
```
### `BuiltinPlugin` — the entry point
```rust
pub trait BuiltinPlugin: Send + Sync {
fn manifest(&self) -> &'static PluginManifest;
fn ribbon(&self) -> Box<dyn CadModule>; // the ribbon tab
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool;
fn on_load(&mut self, host: &mut dyn HostApi) {}
}
```
### `HostApi` — the plugin-facing runtime surface
`dispatch` receives `&mut dyn HostApi`, so a plugin never touches the host's
concrete types:
| Category | Methods |
|----------|---------|
| Document | `document()` / `document_mut()` return a **local cached copy** of the document (API v3); the first access in a dispatch clones the full `CadDocument` over IPC. Use `add_entity()` / `write_record()` for host-visible mutations. `add_entity()`, `bump_geometry()` |
| XDATA | `read_record(handle, app)`, `write_record(handle, record)`, `remove_record(handle, app)` — keyed by entity handle; `write_record` registers the APPID so data round-trips through DWG/DXF |
| Tab state | object-safe `plugin_state_any*` helpers exist for in-process use; out-of-process plugins should keep state inside the plugin crate because `dyn Any` is not serializable |
| Command line | `push_info`, `push_output`, `push_error` |
| Undo / dirty | `push_undo`, `set_dirty` |
| Tab | `tab_index()` |
| Document identity (V5) | `document_path(tab_id)` returns the saved path for a document tab. |
| Notifications (V4) | `on_notification` receives `HostNotification::SelectionChangedV4 { tab_id, handles }` when the active tab's selection changes, plus `DocumentChangedV4` / `DocumentTabClosed`. |
### `export_plugin!` — the C-ABI export
```rust
ocs_plugin_api::export_plugin!(MyPlugin);
```
emits the two symbols the loader looks for:
- `ocs_plugin_api_version() -> u32` — checked **first**, so an API-incompatible
build never runs its code.
- `ocs_plugin_register() -> *mut Box<dyn BuiltinPlugin>` — constructs the plugin
and hands ownership to the host.
---
## Writing a plugin
A plugin is a standalone crate that builds a `cdylib`:
```toml
# Cargo.toml
[lib]
crate-type = ["cdylib"]
[dependencies]
ocs_plugin_api = { git = "https://github.com/HakanSeven12/OpenCADStudio", features = ["host"] }
# Match the host's acadrust so the loaded library is binary-compatible.
[patch.crates-io]
acadrust = { git = "https://github.com/HakanSeven12/acadrust", branch = "main" }
```
Your release build must also use the same `rustc` as the host. Record it in
`plugin.toml` (see below) and pin the toolchain in your CI matrix so every
published asset matches the host's compiler.
```rust
// src/lib.rs
use ocs_plugin_api::host::{BuiltinPlugin, HostApi};
use ocs_plugin_api::manifest::{ApiVersion, PluginManifest};
use ocs_plugin_api::ribbon::{CadModule, IconKind, ModuleEvent, RibbonGroup, RibbonItem, ToolDef};
static MANIFEST: PluginManifest = PluginManifest {
id: "opencad.example", name: "Example Plugin", version: "0.1.0",
description: "…", api_version: ApiVersion::CURRENT,
ribbon_order: 50, xdata_apps: &[], command_prefixes: &["EX_"],
};
struct ExampleModule;
impl CadModule for ExampleModule {
fn id(&self) -> &'static str { "example" }
fn title(&self) -> &'static str { "Example" }
// Returns a slice, not an owned Vec — build once into a static and hand
// back a reference, rather than rebuilding (and dropping) it every call.
fn ribbon_groups(&self) -> &[RibbonGroup] {
static GROUPS: std::sync::OnceLock<Vec<RibbonGroup>> = std::sync::OnceLock::new();
GROUPS.get_or_init(|| vec![RibbonGroup { title: "Demo", tools: vec![RibbonItem::LargeTool(ToolDef {
id: "EX_HELLO", label: "Hello", icon: IconKind::Glyph("◆"),
event: ModuleEvent::Command("EX_HELLO".to_string()),
})]}]})
}
}
struct ExamplePlugin;
impl BuiltinPlugin for ExamplePlugin {
fn manifest(&self) -> &'static PluginManifest { &MANIFEST }
fn ribbon(&self) -> Box<dyn CadModule> { Box::new(ExampleModule) }
fn dispatch(&self, host: &mut dyn HostApi, cmd: &str) -> bool {
match cmd { "EX_HELLO" => { host.push_info("Hello"); true } _ => false }
}
}
ocs_plugin_api::export_plugin!(ExamplePlugin);
```
```toml
# plugin.toml — shipped beside the binary; values mirror MANIFEST
[plugin]
id = "opencad.example"
name = "Example Plugin"
version = "0.1.0"
description = "…"
[opencad]
api_version = 5
ribbon_order = 50
command_prefixes = ["EX_"]
xdata_apps = []
# Both filled at release-build time, not by hand — see the CI step below.
# Declare both; omitting acadrust_source silently skips that half of the
# compatibility check instead of failing it (see "Build-time ABI metadata"
# above), which defeats the point of declaring either field.
rustc_version = "rustc 1.98.0 (hash date)"
acadrust_source = "git+https://github.com/HakanSeven12/cadcodec.git?rev=<short>#<full-40-char-commit>"
```
The full, buildable scaffold is in [`docs/plugin-template/`](plugin-template);
the live reference is the
[`opencad-example-plugin`](https://github.com/HakanSeven12/opencad-example-plugin)
repository.
### Commands
A plugin owns its `command_prefixes` (e.g. `EX_`). The host's command router
calls `try_dispatch` first; a returning `true` consumes the command. A plugin
tool fires `ModuleEvent::Command("EX_FOO")`, which round-trips to
`dispatch(host, "EX_FOO")`.
`ModuleEvent::PluginFileDialog { command, title, filter_name, extensions }` lets a
tool request a native file picker; on selection the host dispatches
`"<command> <path>"` back with the path's original case preserved.
**Known gap: typed/pasted commands still get uppercased even when a leading
`>` is used to keep spaces literal.** The interactive command line
(`Message::CommandInput` in `src/app/update/mod.rs`) unconditionally calls
`s.to_uppercase()` on typed input unless a free-form text prompt is active
(`is_free_text_active()` — cell text, `TEXT`/`MTEXT` bodies). The `>` prefix
and `literal_spaces` mode (added since this was first found) correctly stop
a typed space from auto-submitting the line early — solving multi-word
arguments — but neither one skips the uppercase call, so a plugin command
needing a case-sensitive argument (a file path with mixed case, an embedded
expression, anything not meant to be all-caps) still gets mangled when
typed or pasted directly, even with `>`. Automation (`--serve`/`--mcp`)
bypasses this input path entirely and is unaffected — this is specifically
a typed/pasted-into-the-command-line issue. `ModuleEvent::PluginFileDialog`
sidesteps it for file paths (dispatches back with original case intact, as
noted above) but there's no equivalent for a plugin command that needs
case-sensitive *typed* text more generally. Fix direction: extend whatever
condition currently gates `literal_spaces`/`>` to also skip the uppercase
call, rather than treating "keep spaces literal" and "keep case literal" as
solved by the same escape when a plugin command may need one without the
other.
### Interactive (click-to-place) commands
For tools that collect points — placing a structure, drawing a pipe — call
`host.start_interactive(Box::new(my_cmd))` from `dispatch`, where `my_cmd`
implements `ocs_plugin_api::host::InteractiveCommand`:
```rust
fn on_point(&mut self, pt: [f64; 3]) -> CommandStep {
// … return NeedPoint, Commit(entity), CommitAndEnd(entity), Done, or Cancel
}
```
The host drives it through its normal point-collection flow, so the **same**
command works by clicking in the viewport and by feeding coordinates over the
`--serve` automation API (`run "MY_CMD 0,0 10,10"`). This is what bumped the
API to **v2** — the added `HostApi` method changes the contract's vtable, so v1
binaries are refused at load.
To reference **existing** geometry (e.g. connect a pipe between two structures),
set `needs_object_pick() -> true`; the host then calls
`on_object_pick(handle, pt)` with the clicked entity's handle (read its
XDATA/geometry via `HostApi`). Over `--serve` the pick is supplied as a hex
handle: `run "MY_CMD 2F 30"`.
### XDATA — domain persistence
Store domain data on entities as XDATA (under your `xdata_apps` ids), not in a
side database, so it round-trips through DWG/DXF. `write_record` also registers
the APPID. Document your schemas in the plugin's own `PLUGIN.md`.
---
## Building & distribution
Build per platform and publish to **GitHub Releases**:
```
cargo build --release # → target/release/lib<crate>.so | <crate>.dll | lib<crate>.dylib
```
A release attaches one binary per platform plus `plugin.toml`, with the platform
in the asset name so the host can pick the right one:
```
opencad.example-linux-x86_64.so
opencad.example-windows-x86_64.dll
opencad.example-macos-aarch64.dylib
plugin.toml
```
A GitHub Actions matrix workflow (see the example repo / template) cross-builds
and uploads these on a `v*` tag. It also fills `plugin.toml`'s `rustc_version`
and `acadrust_source` placeholders from the CI run's actual toolchain and
resolved dependency, rather than hand-editing them per release — see
`docs/plugin-template/.github/workflows/release.yml` for the exact `sed`
substitutions, including reading `acadrust_source` out of `Cargo.lock`'s
resolved `acadrust` package entry (`source = "git+...#<hash>"`) since that's
the same value the host embeds at its own build time. The template's CI pins
`rust-toolchain@stable`, which is a floating target — a real release pipeline
should pin the exact toolchain version the host was built with (check
`crates/ocs_plugin_api/src/version_info.rs`'s embedded value, or the host's
own CI config) rather than trusting "whatever's current stable today" to
keep matching it.
---
## Loading
On startup the host scans `<config>/OpenCADStudio/plugins/<id>/` for a
`plugin.toml` + native library (`src/plugin/external.rs`):
```
<config>/OpenCADStudio/plugins/
opencad.example/
plugin.toml
libocs_example_plugin.so # any name with the platform extension
```
For each compatible package the host spawns **itself** in runner mode
(`--ocs-plugin-runner <socket> <cdylib>`). The child process loads the `cdylib`
in its own address space and connects back to the host over an `interprocess`
local socket. The runner checks `ocs_plugin_api_version` and refuses on
mismatch, then calls `ocs_plugin_register` to obtain the boxed `BuiltinPlugin`.
Each plugin runs in a separate OS process, so a plugin crash or memory
corruption cannot affect the host or other plugins. Plugin processes stay
**resident for the session**; external plugins merge into the same ribbon and
`try_dispatch` path the host uses and honour the enable/disable set
(`disabled_plugins` in `settings.txt`).
Two timeouts protect the host from a stuck runner:
| Timeout | Env var | Default | Floor |
|---|---|---|---|
| Spawn (connection) | `OCS_PLUGIN_SPAWN_TIMEOUT_SECS` | 30 s | — |
| Per-call | `OCS_PLUGIN_CALL_TIMEOUT_SECS` | 30 s | `GetManifest`/`GetRibbon` ≥ 5 s, `Dispatch` ≥ 10 s, interactive events/prompt/pick ≥ 2 s |
A call timeout covers the full round-trip, including any nested plugin→host
requests handled inline. When it fires the host kills the runner, marks the
plugin dead, and reports a `CallTimeout` error via the normal plugin error path.
`<config>` is `%APPDATA%` (Windows), `~/Library/Application Support` (macOS), or
`$XDG_CONFIG_HOME` / `~/.config` (Linux).
## Failure handling
| Failure | Behavior |
|---|---|
| Plugin panics | Caught inside the plugin runner child; an error response is returned to the host and stays alive. |
| Plugin crash / hang / malformed message | The host detects a dead process via `try_wait` on the next dispatch or ribbon rebuild; the tab is dropped and an error is logged. |
| Slow or non-responsive call | The per-call timeout (`OCS_PLUGIN_CALL_TIMEOUT_SECS`) fires, kills the runner, and surfaces a `CallTimeout` error. |
| Spawn failure | Reported per-plugin during startup and surfaced in the Plugin Manager / command line. |
| Oversized message | The length-framed transport rejects messages larger than 64 MiB. |
---
## Marketplace
The desktop **Plugin Manager** (`PLUGINS` / `PLUGINMANAGER`, or the Start-page
button) installs plugins from GitHub Releases. In the browser, the same entry
points show a desktop-download notice because marketplace packages are native
dynamic libraries:
- **Curated registry** — [`plugins/registry.json`](../plugins/registry.json) in
this repo lists discoverable plugins. The host fetches it from `main` at
runtime and shows each entry under *Available plugins*. To list a plugin, open
a PR adding `{ "repo", "name", "description" }` (see
[`plugins/README.md`](../plugins/README.md)); merged PRs reach every user with
no app update.
- **Manual link** — *Add a repository* (`owner/repo`) for unlisted or private
dev repos; linked repos persist in `settings.json`.
- **Install / upgrade / reinstall** — pick a release from the dropdown and
*Install*; the host downloads the platform asset + `plugin.toml` into the
plugins folder, checking `api_version` first. Reinstalling overwrites and
clears any stale library; picking a newer release upgrades. Changes take effect
on the next restart (the running library stays resident).
- **Uninstall** — removes the package folder (effective next restart).
- **Enable/disable** — toggles a loaded plugin's ribbon tab + dispatch without
uninstalling.
**Known gap: one linked repo is treated as one plugin's release history, even
when it hosts several.** `fetch_releases`/`fetch_release_info`
(`src/plugin/marketplace.rs`) fetch and parse *every* release under a linked
`owner/repo` into one flat `Vec<ReleaseInfo>`, with no filter on the parsed
manifest's own `id` field — which is already available at that point, since
each release's `plugin.toml` is downloaded and parsed to build `ReleaseInfo`
in the first place. A repo that ships more than one plugin (tag-namespaced
releases like `pluginA-v1.0.0`, `pluginB-v1.0.0`) will surface every other
plugin's releases as if they belonged to whichever one the user is looking
at, and any future "is there a newer version" check that walks this same
list by date/tag would need the same fix. A user can still install correctly
by picking the right tagged release (each release's own bundled `plugin.toml`
is what actually gets installed), but the discovery/version list itself
doesn't distinguish plugins sharing a repo. Fix direction: filter
`fetch_release_info`'s result (or its caller) to releases whose manifest
`id` matches the plugin currently being viewed/installed, the same `id`
`ReleaseInfo` already carries.
---
## Compatibility & ABI
Plugins are loaded as `cdylib`s by a plugin-runner child process. The host spawns
this child from its own executable (`--ocs-plugin-runner` mode), so the runner
and host always share the same `ocs_plugin_api` build. The runner checks
`ocs_plugin_api_version` before any plugin code runs. Each plugin runs in its
own OS process, so a plugin crash or memory-corruption bug cannot take down the
host process itself — but process isolation alone does **not** mean a
toolchain mismatch is safe to ignore: the plugin and host still exchange data
across the shared `ocs_plugin_api` types over IPC, and a struct-layout or ABI
difference between toolchain versions could corrupt that exchange in ways
subtler than an immediate crash. That's why, independently of process
isolation, API v4+ *also* enforces an exact `rustc_version` match (and,
correctly declared, `acadrust_source` — see above) before loading a plugin at
all, rather than relying on isolation to make a mismatch merely "someone
else's problem."
A future hardening step is a `#[repr(C)]` vtable (a true C ABI) so binaries built
by any toolchain interoperate — required before trusting prebuilt binaries from
arbitrary build environments.
---
## Roadmap
Done:
- [x] Stable `ocs_plugin_api` crate — dependency-free core + `host` feature
(`HostApi` / `BuiltinPlugin` / `export_plugin!`).
- [x] Runtime discovery + out-of-process loading (host spawns itself in runner
mode) with an `api_version` gate and `interprocess` local-socket IPC.
- [x] XDATA helpers, `ModuleEvent::PluginFileDialog`, per-tab plugin state.
- [x] Marketplace — curated registry + manual repo link, install / upgrade /
reinstall / uninstall, enable/disable.
- [x] Interactive command round-trip over IPC (prompt, point/enter/object-pick).
- [x] Spawn and per-call IPC timeouts so a stuck runner cannot freeze the host.
- [x] External automation (`--mcp` — local stdio MCP/JSON-RPC server for AI
clients; `--serve` — headless JSON automation server; both drive OCS
from a separate process without the GUI, per `src/cli.rs`/`src/mcp.rs`/
`app::serve`). This closes the "drive OCS headless from a process" goal
of issue #29; this line previously and incorrectly listed it as not yet
started.
Next:
- [ ] Incremental document snapshots instead of cloning `CadDocument` over IPC.
- [ ] `#[repr(C)]` vtable / strict handshake for cross-toolchain binaries.
- [ ] Trust: checksums / signatures before spawning plugin processes.
- [ ] Interchange (LandXML / SWMM) and live `on_entity_committed` hooks.
- [ ] Close the `acadrust_source` enforcement gap noted above (see
"Build-time ABI metadata") — an omitted field is currently accepted as
compatible instead of rejected, unlike `rustc_version`.
---
## Reference
| Piece | Location |
|-------|----------|
| Contract crate + runtime | [`crates/ocs_plugin_api`](../crates/ocs_plugin_api) |
| Internal API architecture | [`crates/ocs_plugin_api/ARCHITECTURE.md`](../crates/ocs_plugin_api/ARCHITECTURE.md) |
| Plugin runner implementation | [`crates/ocs_plugin_api/src/runner.rs`](../crates/ocs_plugin_api/src/runner.rs) |
| Host spawn logic | [`crates/ocs_plugin_api/src/process.rs`](../crates/ocs_plugin_api/src/process.rs) |
| Host plugin integration | `src/plugin/`, `src/app/plugin_host.rs` |
| Core module registry generator | `build.rs` (writes to `OUT_DIR`, included by `src/modules/registry.rs`) |
| Marketplace + registry | `src/plugin/marketplace.rs`, [`plugins/registry.json`](../plugins/registry.json) |
| Template scaffold | [`docs/plugin-template/`](plugin-template) |
| Live example plugin | [`opencad-example-plugin`](https://github.com/HakanSeven12/opencad-example-plugin) |