RFC 0002: Plugins defines the plugin API and phases. Phase 1 splits the host into two app-agnostic crates:
crates/craft-pluginowns the generic WIT contract, manifest, permissions, entry input schemas and typed host errors. It has no wasmtime dependency.crates/craft-plugin-wasmruns WebAssembly components against that contract with fuel, memory and stack limits. Its linker exposes no network imports.
An app implements PluginHost: it supplies command schemas, each command's
read or edit effect, command execution, transaction begin/commit/rollback,
and the folders that file grants may resolve to. One successful plugin call is
one undo step; a failed guest call rolls back the transaction. Granted file writes
are staged and published only after the guest succeeds and the app commits. A rollback
deletes staged files without replacing their targets. A filesystem failure during publication
after app commit is reported and may leave some files changed. App commands remain
JSON data rather than app-specific WIT types. Both crates are Shared crates;
cargo xtask layers forbids direct and indirect SolidCraft dependencies.
SolidCraft's adapter lives in crates/engine and uses the same command surface
as MCP. Entries appear as plugin_id.function; plugin.list and
engine.describe expose their declared inputs. Selection and target inputs
are lists. Entries may declare named menu or ribbon slots; each app decides
which slots exist and draws forms from the input schemas. Plugins ship no UI
code. SolidCraft retains approval, cancellation and plugin preferences.
Install plugin… in Preferences > Plugins (desktop only) picks a plugin .zip.
One confirmation screen shows its name, version, summary, requested permissions and entry placements.
Each command group explains its access in plain words: files, designs, application settings or other plugins.
Enter an absolute local path for each requested folder. Each folder shows its requested read/write access.
When the plugin asks for more memory than the ceiling in Preferences > Plugins (128 MiB unless
changed), the screen says how much it asks for and what the ceiling is.
Install approves the displayed command groups, folders and memory, publishes the package and stores the
approvals; Cancel changes nothing. Installation never runs plugin code. Approved memory applies to
that exact package only: a replaced or changed package asks again. Fuel still has to fit the ceiling.
Programs use the same plugin.install command without opening dialogs:
{
"path": "gears.zip",
"approval": "COPY_THE_APPROVAL_FROM_THE_REVIEWED_DRY_RUN",
"approve": {
"command_groups": ["file"],
"folders": [{"name": "exports", "path": "/home/user/exports", "read": false, "write": true}],
"memory_mib": 1024
}
}Approvals may only narrow the manifest's requests. An unrequested group, folder name or access mode
returns a typed refusal. memory_mib is either omitted (the plugin runs under the app's ceiling) or
exactly the manifest's runtime.limits.memory_mib.
Omitting approve grants no groups, folders or memory, including when replacing a plugin.
Providing approve replaces that plugin id's stored approvals; grants the new manifest no longer requests
cannot survive replacement. plugin.run checks stored grants against the current manifest on every call.
A package copied into the plugin directory by hand also starts with no grants.
Set "dry_run": true to check and describe the zip, including approval validation, without writing
files or grants. The response includes an approval hash of the exact manifest and component.
It also includes archive, a hash of the whole .zip, which covers the plugin's other files too.
Send both as approval and archive when installing the reviewed package. A changed package returns
a typed invalid_input error for approval or archive before any files or grants are written. The dialog always
sends both hashes; programmatic callers may omit it to install directly without a prior review.
Replacing an installed plugin with the same id requires "replace": true.
The response includes name, version, description, capabilities, permissions, requested limits, the app's
host_limits, app commands,
namespaced entries, declared entry_points with input schemas and slots, replacement information,
the target folder and whether it was installed. A dry run reports installed: false.
Dry-run description is the package review path; there is no separate review command or folder installer.
A zip holds one plugin, with plugin.json and its component at the top or inside one top-level folder.
The existing solidcraft_io::safe_zip reader refuses absolute paths, .., symlinks and device entries.
The installer limits the archive to 256 entries, each file to 16 MiB, and input and unpacked content to
64 MiB. The manifest has a separate 16 KiB cap. Before publication, the host parses the manifest and
refuses unknown or newer capabilities. It unpacks into a hidden .install-… folder beside the installed
plugins, then renames it to plugins/<id>/. The entries resolve immediately, with no restart.
Each installed plugin card in Preferences > Plugins has an enable switch and Remove action. Remove asks for confirmation, then deletes the package folder and its stored grants. Cancelling Preferences does not restore a removed package. Removing a plugin preserves its data in saved designs. Approved grants and removal survive closing or resetting Preferences. Grants are host preferences, never design data; preferences retain at most 256 plugin ids, four command groups and 32 folders per id. The browser Plugins page remains desktop-only.
The command surface exposes management without opening dialogs:
| Command | Parameters | Result |
|---|---|---|
plugin.install |
{path, approval?, archive?, replace?, dry_run?, approve?: {command_groups, folders: [{name, path, read, write}], memory_mib?}} |
Package description and installation status, including declared entry_points |
plugin.list |
{} |
{plugins: [{manifest, commands, disabled}]} |
plugin.remove |
{id} |
{id, removed: true} |
All commands expose input and output schemas through engine.describe. Removal validates the id
and confines deletion to the host's plugin directory. Installation and removal do not edit the design.
On desktop, send plugin.install and plugin.remove as separate engine.execute requests.
engine.batch and engine.script refuse these external file operations before any earlier step runs.
Direct requests use a worker to keep the window responsive.
The Plugins menu in the app bar lists every installed plugin entry, including entries without slots. Disabled entries remain listed but cannot be selected. Discovery examines at most 1,024 package folders and 8,192 host slot items. Only enabled plugins contribute host menu and ribbon items. The host draws each slot item with the plugin's name, a plugin badge and the entry name. Plugins declare placement in their manifests and supply no widget, menu or ribbon code.
| Declared slot | Placement (names ignore case) |
|---|---|
menu named File or Help |
The existing File or Help menu; each item includes the plugin's name |
Any other menu name |
No host menu item; the entry remains in the Plugins menu |
ribbon named for an existing Design ribbon tab |
A group named for the plugin in that tab |
Any other ribbon name |
One Plugins ribbon tab, with a group per plugin |
The Design tab names are Model, Surface, Assemble, Insert, Inspect, Manage, View, Sketch,
Sheet Metal and Weldments. Existing tab visibility and workspace rules still apply.
For example, {"kind":"menu","name":"File"} places an entry in File;
{"kind":"ribbon","name":"Model"} places it in Model.
Clicking a File, Help or ribbon item opens the same input-schema dialog as the Plugins menu.
The host finds the entry by its namespaced <plugin-id>.<entry> command. The dialog uses the same
enabled state and grant checks from every placement. Missing command-group grants leave Run off
with an explanation. Opening the dialog executes no plugin code; Run submits the existing
plugin.run transaction.
Preferences and the Plugins menu share plugin_install::start(app) in
crates/ui-egui/src/plugin_install.rs. It selects a zip, calls plugin.install with dry_run: true,
draws the confirmation screen and submits plugin.install with the reviewed approval and archive hashes and selected approve object.
Successful installation persists grants in desktop preferences; successful removal deletes them.
There is one package reader and one installation confirmation screen.
plugin_menu::list in crates/ui-egui/src/plugin_menu.rs and Preferences use the same package
inventory as slot discovery. The list includes each plugin's enabled state and requested
command groups. File, Help and ribbon clicks resolve their namespaced entry against that list,
then call plugin_menu::open_entry(app, p, e), as the Plugins menu does. Unknown menu names need no
fallback rows because the Plugins menu already lists every entry.
The shared dialog draws the entry's input schema and supplies the package's content approval hash when invoking
plugin.run. An entry without fields receives {}. Slot clicks alone do not execute a plugin.
Installing, enabling or discovering a plugin never runs an entry. Installation grants do not replace
execution consent or the manifest-and-component identity checked by plugin.run.
A string with a fixed set of values becomes a dropdown. With enum the dropdown shows the values
themselves; to show words instead, list the options as oneOf, each {"const": value, "title": shown}
(with an optional description). The dropdown shows the titles and the plugin receives the value:
"up_axis": {"type": "string", "title": "Up axis", "default": "as_is", "oneOf": [
{"const": "as_is", "title": "As in the file"},
{"const": "stand_up", "title": "Stand up (Y-up to Z-up)"}
]}The WIT package is craft:plugin@0.1.0. Today's surface is one capability, commands@0.1:
running app commands, logging, progress and cancellation, named files, a clock, and the exported
entry. A plugin's plugin.json lists what it needs, for example
"capabilities": ["commands@0.1"]; the host refuses an unknown or newer capability when it reads
the manifest, with a plain message such as "needs commands 0.2; update SolidCraft or the plugin".
ui, viewport, formats and storage arrive later as their own versioned interfaces. While a
version is 0.x, a minor version may break the one before it.
A plugin can call every command in the MCP catalog that its manifest lists. Commands that reach
past the open design are grouped (file, doc, app, plugin) and refused unless the
manifest asks for the group in permissions.command_groups and the user grants it.
File access requires approved folder grants. The reserved net permission is
refused with a typed error. Traps, missing permissions, failed commands, limits
and invalid manifest fields return structured errors. Later phases add
import/export, author tooling and browser execution after the
phase 1 design review.
A design container reserves plugins/<plugin-id>/ for a plugin's own files. They are kept and
saved back untouched when the plugin is missing, and opening a design never fails because a plugin
is missing. The storage capability will let plugins read and write them.