Files
l0ng-ai 953e844541 feat(plugins): community plugins, version compatibility and categories in the marketplace (#1226)
* feat(plugins): list community plugins from a registry in the marketplace

The marketplace index can now carry plugins of other publishers, published
from their authors' repositories through the l0ng-ai/openidea-plugins
registry.

- `openidea-plugin index` with a key replaces only that publisher's plugins
  (it used to drop every other entry); without a key it indexes each package
  against the publishers the index already lists, checking the key and the
  id prefix instead of only the signature itself.
- `openidea-plugin merge` takes another index's publishers, plugins and
  revocations in place of every publisher but `--keep`; the other index can't
  list or revoke the kept publishers' plugins.
- `openidea-plugin check` accepts a `.oiplugin` file.
- Releases merge the registry's index into plugins-index.json and attach the
  Linux x64 tool; plugins-index.yml refreshes the community plugins in the
  latest release hourly or when the registry signals a change.
- plugins/README.md and CONTRIBUTING.md describe official and community
  plugins.

* feat(plugins): offer the newest plugin version this IDE can load

Index entries record the plugin API each version needs (`engine`: the
manifest's `engines.openidea` narrowed to the API its component was built
for), and an index may list several versions of a plugin. Settings › Plugins
offers the newest version this IDE can load; with none, the plugin is shown
as incompatible with the API it needs, and downloading it is refused.

`openidea-plugin index` keeps, of each plugin, the newest version per API
requirement, so users of an older IDE keep the last version built for them;
`--download-base` makes downloads absolute, and the release workflow points
them at the release that published them. Scratch directories are now unique
per call.

* feat(plugins): file marketplace plugins under categories

`plugin.toml` takes `categories` (up to 3 of languages, themes, icon-themes,
linters, vcs, ai, frameworks, tools, other). A plugin that declares none is
filed under what its contributions imply (themes, icon themes, languages),
else under other; categories an IDE doesn't know are ignored, and `check`
warns about them. Index entries carry the categories, and Settings › Plugins
› Available filters by them and shows them in a plugin's details. The
official plugins declare theirs.

* feat(plugins): let a marketplace index point to where it moved

An index with `moved` sends the IDE to the index at that URL (relative or
absolute, up to three hops; an https index can only move to https). Downloads
resolve against the index actually read, so a marketplace can change hosts
while released IDEs keep the address they were built with.

* fix(plugins): full-width category filter and aligned incompatibility note

* fix(plugins): translate the AI category in Chinese

* feat(plugins): keep a plugin's signer across updates and announce updates

- Index format: fields are only added; an IDE reading a newer `format`
  still applies its revocations and `moved`, lists no plugins and asks to
  be updated, instead of failing to read the index.
- Key continuity: an installed plugin updates silently only to a package
  signed with the same key, or a key it endorsed (`KeyEntry.endorsement`,
  `index --previous-key`, `openidea-plugin endorse`). Another signer, or an
  unsigned package replacing a signed one, is shown on the install page and
  needs the user's confirmation (`Consent.signerChange`).
- Official key: release builds carry the official publisher's public key
  (`OPENIDEA_OFFICIAL_PLUGIN_KEYS`, derived from PLUGIN_SIGNING_KEY);
  `openidea.*` plugins must be signed with it or a key it endorsed,
  whatever the index lists.
- Updates: the daily marketplace check announces new versions once, with
  Update All (also in the gear menu and as an action). An update that asks
  for nothing new installs with the grants the installed version has.
- Index entries need `engine`; `categories` beyond the first three are
  ignored instead of rejecting the manifest.
2026-11-04 18:24:43 +08:00
..
2026-10-22 18:55:10 +08:00

Writing OpenIDEA plugins

OpenIDEA plugins are sandboxed WebAssembly components (wasmtime, component model, WASI preview 2). A plugin can register commands (they become IDE actions you can search for and bind to keys), read project files, edit the focused editor, react to workbench events, run background tasks, show notifications and status bar items, keep data in its own storage, read and fetch git repositories, provide completion, quick fixes, navigation and gutter markers, call web services and run approved programs, show tool windows and custom editors, and contribute menus, shortcuts, settings, themes, file icon themes and languages. Plugins that only contribute data (themes, icons, languages, menus, settings) need no code at all.

Preview. The plugin system ships as a preview: until it is declared stable the API (openidea:plugin@1.0) may still change, and the official plugins are versioned 0.x. See Evolving the plugin API below.

plugins/
└─ official/
   ├─ harbor-night/           # data only: a colour theme (UI and editor scheme)
   ├─ material-icons/         # data only: a file icon theme
   ├─ solidity/               # language + syntax provider + run configurations
   ├─ string-tools/           # 26 text commands, versioned multi-cursor edits
   ├─ key-promoter/           # action events, storage
   ├─ git-toolbox/            # on-schedule fetch, tasks, VCS, status bar item
   ├─ checkstyle/             # one-shot process, diagnostics, quick fixes
   ├─ ai-completion/          # inline completion over the network, secrets
   ├─ mybatis/                # navigation, gutter markers, completion across files
   ├─ csv-editor/             # custom editor sharing the text document
   ├─ excalidraw/             # web view custom editor, file icons, a template
   ├─ graphviz/               # web view with code: live preview, problems from the page, a language
   ├─ regex-tester/           # html view talking to its code, settings, the editor's selection
   └─ license-header/         # file-opened events, quick fix, project scan in a task
sdk/rust/openidea-plugin/     # the Rust SDK (host API, Plugin trait, mock host)
crates/ide-plugin/tests/fixtures/  # test plugins: hello, jobs, language-toy, misbehave

The plugin API is defined in wit/ (openidea:plugin@1.0.0, world plugin).

1. Quick start

The openidea-plugin tool scaffolds, builds, checks and links plugins:

cargo install --path crates/ide-host --bin openidea-plugin   # or: cargo run -p ide-host --bin openidea-plugin -- …

openidea-plugin new my-plugin                          # Rust command plugin (SDK)
openidea-plugin new my-theme --template theme          # also: icon-theme, language
cd my-plugin
cargo test                                             # logic, against the SDK's mock host
openidea-plugin build                                  # → dist/plugin.wasm, then `check`
openidea-plugin link                                   # load this folder in the IDE

build runs cargo build --release --target wasm32-wasip2 (installing the target through rustup if needed), copies the component to the manifest's wasm path and runs check. check validates the package exactly as Install Plugin from Disk… would — manifest, every contribution, the SVG sanitiser, the component — and that the component imports nothing but the plugin API and WASI. It prints what the user will be asked to grant.

link adds the folder to links.json in the IDE's plugins directory ($OPENIDEA_PLUGINS_DIR, default <app-data>/ai.l0ng.openidea/plugins). Linked plugins are read from the folder itself, so the edit loop is build then Settings › Plugins › Reload. unlink and links undo and list.

2. The package

A plugin is a directory with a plugin.toml:

id = "acme.word-tools"            # [a-z0-9][a-z0-9_-]*, optionally with dotted namespaces
name = "Word Tools"
version = "1.2.0"                 # semver
description = "Counts and transforms words."
authors = ["You <you@example.com>"]
homepage = "https://example.com"  # optional
license = "MIT"                   # optional
change-notes = "1.2.0 — …"        # shown under What's New
categories = ["tools"]            # marketplace categories, see below
engines.openidea = "^1.0"         # the plugin API this package needs
wasm = "dist/plugin.wasm"         # omit for data-only plugins
activation = ["on-language:markdown"]   # optional, see below

[capabilities]
required = ["project.read", "editor.write"]
optional = ["net:api.example.com"]

[[contributes.commands]]
id = "count"                      # [a-z0-9][a-z0-9._-]*
title = "Word Tools: Count Words"
category = "Word Tools"           # group in Search Everywhere / Keymap
when = "editorFocus"              # optional: same expressions as built-in actions

categories (up to 3) file the plugin in the marketplace: languages, themes, icon-themes, linters (linters and formatters), vcs, ai, frameworks, tools, other. Without them, a plugin contributing themes, icon themes or languages is filed under those, any other under other. Categories an IDE doesn't know, and any after the first three it knows, are ignored (check warns about both).

All paths are relative to the package directory and may not leave it. A manifest is validated as a whole: if one contribution is invalid, nothing is installed or loaded. Contribution types the IDE does not know (a newer API) are skipped with a warning.

Manifests in the 0.1 format (openidea-api = "0.1", permissions = [...]) still load (their permissions become required capabilities; contributions work), but 0.1 code no longer runs: rebuild it against the 1.0 SDK.

Contribution points

Point Declaration Code needed
commands id, title, category?, when? to run them
themes path (theme JSON), editor-scheme? no
iconThemes id, label, path (icon theme JSON, see §5) no
languages path (languages/<id>.toml format, see §6) no
runDetectors id, name, files, command, args?, test? (§6.2) no
providers kinds, languages (editor providers, see Providers) yes
secrets name, label, description? (see Network) to use them
menus menu (editor.context, projectView.context, editorTab.context, tools), command, when? no
keymaps command, key, mac? (Ctrl+Alt+C; a chord: Meta+K Meta+C) no
statusBar id, text, tooltip?, command?, align?, priority? no
settings schema (JSON file), title? — one table, see Settings no
views id, title, icon?, location (left, right, bottom), web? (see Web views) without web
customEditors id, title, patterns (file names), priority (default, option), layout?, web? without web
fileIcons id, patterns (file names), icon (SVG), iconDark? — whatever icon theme is in use no
fileTemplates id, name, extension, path (the new file's content; template variables work) — New menu no

Declared contributions are live as soon as the plugin is enabled — commands show in Find Action, menus and the status bar, shortcuts work, themes and icon themes can be applied, settings pages are there — before any plugin code is loaded. Menus, shortcuts and status bar items refer to the plugin's declared commands. A plugin shortcut never takes a keystroke the IDE (or the user) already uses; the user can rebind it in the keymap editor.

Activation

Code is instantiated lazily: when one of the plugin's commands runs, or when an event in activation fires:

Event Fires when
on-command:<id> the command runs (implied for every command)
on-language:<id> a file of that language is opened (*: any)
on-file:<glob> a project file matching the glob is opened (**)
on-view:<id> one of the plugin's views is shown
on-schedule:<secs> every secs seconds (at least 60): Event::Scheduled
on-event:<kind> a workbench event of that kind (see Events)
on-startup-finished shortly after the workbench is ready (use sparingly)

Until then the plugin is listed as Inactive and costs nothing at start-up. An instance unused for 5 minutes is reclaimed (its deactivate runs) and the plugin is Inactive again until its next event; on-schedule activates it when due but does not keep it alive. Keep nothing in memory you can't rebuild in activate.

Capabilities

Code can only do what its capabilities allow, and the user approves them when installing (or when a new version asks for more). Low-risk capabilities are listed; high-risk ones are called out as equivalent to running code on the machine. Users can revoke optional capabilities in Settings › Plugins.

Capability Risk Unlocks today
project.read low Project::root, read_file, find_files, host::projects, publish_diagnostics
editor.read low TextEditor::document, selections
editor.write medium TextEditor::apply_edits
vcs.read low Project::vcs_status (branch, upstream, ahead/behind, changes)
vcs.write medium Project::vcs_fetch (trusted projects only)
storage low host::storage (1 MiB per plugin)
events low file opened/saved, editor focused, repository changed events
events.input medium action-performed events (which actions you run, and how)
process:<program> high Project::run_process, language servers, debug adapters, runs
net:<host> medium host::fetch to that host (*.example.com: its subdomains)
secrets medium {{secret:<name>}} in request headers, host::secret_is_set
clipboard medium the clipboard API in the plugin's web views (keyboard copy / paste work without it)

process: capabilities are also derived from what the contributions start: a language's language-servers and debugger commands and a run detector's command. Those are optional — refusing one keeps the rest of the plugin working. On grant the program is resolved to an absolute path and its content hash is recorded; it is checked again right before every start, and a replaced file is refused until the user approves it again. In a remote project the server resolves and pins the program on its own machine (see Remote projects). fs.read:<path> can be declared and granted; it unlocks nothing yet.

Logging, notifications, status bar items, tasks and run-time contributions are always available. Calling a gated function without its capability returns Error::Denied("<capability>") — the plugin keeps running and can degrade (Git Toolbox still shows the branch without vcs.write).

3. Writing code (Rust SDK)

openidea-plugin new writes this for you. Cargo.toml:

[package]
name = "openidea-plugin-word-tools"
version = "0.1.0"
edition = "2024"

[workspace]                       # keep it out of any parent workspace

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
openidea-plugin = { path = "…/sdk/rust/openidea-plugin" }

[profile.release]
opt-level = "s"
lto = true
strip = true
panic = "abort"

src/lib.rs:

use openidea_plugin::{Error, Invocation, NotificationLevel, Plugin, host, register_plugin};

#[derive(Default)]
struct WordTools;

impl Plugin for WordTools {
    fn run_command(&mut self, id: &str, ctx: &Invocation, _arg: Option<&str>) -> Result<Option<String>, Error> {
        let editor = ctx.editor().ok_or("no file is open")?;
        match id {
            "count" => {
                let words = editor.document()?.text.split_whitespace().count();
                host::notify(NotificationLevel::Info, "Word count", &format!("{words} words"));
                Ok(None)
            }
            // Every selection (a cursor: empty) in one undoable change.
            "shout" => {
                editor.transform_selections(|s| s.to_uppercase())?;
                Ok(None)
            }
            _ => Err(Error::NotFound(format!("command {id}"))),
        }
    }
}

register_plugin!(WordTools);

On wasm32-wasip2 the SDK calls the IDE; on your machine it calls the SDK's mock host, so the logic is unit-tested with cargo test:

#[test]
fn counts_and_shouts() {
    let host = openidea_plugin::mock::MockHost::install();
    host.open_editor("/project/a.md", "markdown", "one two three", &[(0, 4, 0, 7)]);
    WordTools.run_command("count", &host.invocation(), None).unwrap();
    assert_eq!(host.notifications()[0].body, "3 words");
    WordTools.run_command("shout", &host.invocation(), None).unwrap();
    assert_eq!(host.editor_text(), "one TWO three");
}

The mock enforces capabilities like the IDE (MockHost::install().with_capabilities(&[...])), keeps a versioned editor document (type_in_editor makes edits stale), and records notifications, status items, storage, subscriptions, tasks, fetches, commands, languages, themes and log lines.

Any language with component-model tooling works (componentize-py, jco componentize, TinyGo): the only contract is the plugin world in wit/ (plugin-syntax adds the syntax provider). Build it yourself, then run openidea-plugin check.

The API

Rust SDK WIT Notes
Plugin::run_command(id, ctx, arg) export commands.run ctx is the window: ctx.project(), ctx.editor(). An error shows "