Plugin Development Guide
Nexion plugins are JavaScript bundles that run on Apple's JavaScriptCore engine. Each plugin gets its own isolated JSContext on a dedicated serial queue, so it never blocks the UI. Plugins can add toolbar buttons, commands, keybindings, sidebar panels, context menu items, and a preferences form: all through a declarative manifest and a simple JavaScript API.
Bundle Structure
A plugin is a directory with the .nexionplugin extension:
my-plugin.nexionplugin/
├── manifest.json # Plugin metadata and contributions
├── main.js # Entry point
├── icon.png # Optional plugin icon (shown in Preferences)
└── assets/ # Optional additional resourcesManifest
The manifest.json defines your plugin's identity, permissions, and contributions:
{
"identifier": "com.example.my-plugin",
"name": "My Plugin",
"version": "1.0.0",
"description": "A description of what this plugin does.",
"author": "Your Name",
"homepage": "https://example.com/my-plugin",
"license": "MIT",
"icon": "icon.png",
"main": "main.js",
"activationEvents": ["onStartup"],
"permissions": [
"process.spawn",
"filesystem.read",
"filesystem.write",
"terminal.send",
"terminal.read"
],
"contributes": {
"tools": [],
"commands": [],
"keybindings": [],
"contextMenu": [],
"sidebarPanels": []
},
"preferences": { "sections": [] }
}Manifest Fields
| Field | Type | Description |
|---|---|---|
identifier |
String | Unique reverse-domain identifier (e.g., com.example.my-plugin) |
name |
String | Human-readable plugin name |
version |
String | Semantic version (e.g., 1.0.0) |
description |
String | Short description of the plugin |
author |
String | Plugin author name |
homepage |
String | URL to the plugin's homepage or repository |
license |
String | SPDX license identifier (e.g., MIT) |
icon |
String | Path to plugin icon (relative to bundle root) |
main |
String | Entry point JavaScript file |
activationEvents |
Array | Currently only onStartup is supported |
permissions |
Array | Required permissions (see Permissions section) |
contributes |
Object | Declarative UI contributions (tools, commands, keybindings, etc.) |
Contributions
The contributes object declaratively registers UI elements.
Tools (Toolbar Buttons)
"tools": [
{
"id": "my-plugin.build",
"displayName": "Run Build",
"tooltip": "Build the project",
"systemIcon": "hammer",
"action": "command",
"command": "my-plugin.runBuild",
"commandArgs": ["--release"],
"execution": "terminal"
}
]| Field | Description |
|---|---|
id |
Unique tool identifier |
displayName |
Label shown in the toolbar |
tooltip |
Tooltip text on hover (optional) |
systemIcon |
SF Symbol name for the button icon |
icon |
Path to a custom icon image (alternative to systemIcon) |
action |
command to execute a registered command |
command |
The command identifier to execute |
commandArgs |
Optional arguments passed to the command |
execution |
terminal or floating |
Commands
Register commands that appear in the Command Palette:
"commands": [
{
"id": "my-plugin.runBuild",
"title": "Run Build"
},
{
"id": "my-plugin.cleanProject",
"title": "Clean Project"
}
]Keybindings
"keybindings": [
{
"command": "my-plugin.runBuild",
"key": "cmd+shift+b"
}
]
Modifiers: cmd, ctrl, alt, shift. Combine with +.
Context Menu
Add items to right-click context menus. The id becomes the command that gets dispatched: register a handler for it in your main.js using nexion.contextMenu.register().
"contextMenu": [
{
"id": "my-plugin.openInEditor",
"title": "Open in Editor",
"when": "file"
}
]
The when field controls where the menu item appears:
- •
file: Right-clicking a file - •
folder: Right-clicking a folder - •
terminal: Terminal context menu - •
always: All context menus (default)
Sidebar Panels
Register custom sidebar panels. Content is set dynamically from JavaScript using nexion.ui.updateSidebarPanel() and rendered as Markdown.
Each registered panel becomes a card in the sidebar, alongside Finder, Git, Notes and Attach: it can be opened beside the others, moved between the sidebar's two columns, resized and closed. It also gets a row of its own, with its title and icon, in the sidebar's + menu.
A panel arrives switched off: installing a plugin does not take a piece of the user's sidebar without being asked. Calling nexion.ui.showSidebarPanel() switches the card on and brings it into view, which is the intended way in after an action of the user's. When the plugin is disabled or removed, its card goes with it and the room it was taking goes back to the cards still there, so never assume a panel stays on screen between launches.
"sidebarPanels": [
{
"id": "my-plugin.status",
"title": "Build Status",
"icon": "hammer",
"scope": "project"
}
]| Field | Type | Required | Description |
|---|---|---|---|
id |
string | yes | Panel identifier (used by nexion.ui.updateSidebarPanel()) |
title |
string | yes | Card title, shown on the card and in the sidebar's + menu |
icon |
string | no | SF Symbol name. Default: puzzlepiece |
scope |
string | no | "global" (default) or "project". When set to "project", panel content is stored separately for each project and switches automatically when changing tabs |
Use "scope": "project" for panels whose content depends on the active project (e.g. dependencies, TODOs, env vars). Use "global" (or omit the field) for panels with shared content across all projects (e.g. snippets, timers).
Preferences (form UI)
Declare a preferences schema to give your plugin a configuration form inside Preferences → Plugins. When present, a Preferences button appears next to the plugin row; clicking it opens a sheet rendered from the schema. Values are persisted under the same store as nexion.settings, so the plugin reads them at runtime with nexion.settings.get(key): no extra API needed.
"preferences": {
"title": "Formatter Settings",
"description": "Configure how this plugin formats your code.",
"sections": [
{
"label": "General",
"description": "Core behaviour",
"fields": [
{ "type": "text", "content": "These settings apply to every project." },
{ "type": "select", "key": "tool", "label": "Formatter",
"options": ["auto", "prettier", "black"], "default": "auto" },
{ "type": "number", "key": "timeout", "label": "Timeout (s)", "default": 30 },
{ "type": "boolean", "key": "verbose", "label": "Verbose logging", "default": false }
]
},
{
"label": "Credentials",
"fields": [
{ "type": "secret", "key": "apiKey", "label": "API Key", "required": true,
"description": "Stored locally in your Nexion config." },
{ "type": "multiline", "key": "header", "label": "Custom header",
"placeholder": "// Prepended to every formatted file" }
]
}
]
}
Top-level fields: title, description, sections[]. Each section has an optional label / description and a list of fields.
| Field type | Renders as |
|---|---|
text | Static description block (no key; uses content) |
string | Single-line text field |
multiline | Multi-line text editor |
secret | Secure (masked) input for tokens / API keys |
number | Numeric input (int or double) |
boolean | Toggle switch |
select | Dropdown; options can be strings or { value, label } |
Per-field options: key (storage key, required except for text), label, description, placeholder, default, required, options (for select), visible.
visible defaults to true. Set it to false to omit the field from the form entirely, the user never sees it, and Reset to Defaults leaves the stored value untouched. The plugin can still read and write the key via nexion.settings.get/set(). Useful for keys the plugin owns internally: last-used file, cached tokens, computed state, license tier.
// Read the value the user configured
var tool = nexion.settings.get("tool") || "auto";
var verbose = nexion.settings.get("verbose") === true;
var apiKey = nexion.settings.get("apiKey");
// React to changes made in the Preferences sheet
nexion.settings.onDidChange("tool", function(newValue) {
nexion.notifications.showInfo("Formatter switched to " + newValue);
});
The sheet also offers Reset to Defaults, which rewrites the store with each field's declared default. Fields without a key (static text blocks) are purely presentational.
Permissions
Plugins must declare the permissions they need. Each permission grants access to specific APIs:
| Permission | Grants Access To |
|---|---|
process.spawn |
nexion.process: spawn external processes |
filesystem.read |
nexion.fs: read files and directories |
filesystem.write |
nexion.fs.writeFile(): write files |
terminal.send |
nexion.terminal.sendCommand(), clear(), runInFloatingWindow() |
terminal.read |
nexion.terminal.onOutput(): listen for terminal output |
APIs that don't require permissions: nexion.commands, nexion.notifications, nexion.ui, nexion.data, nexion.settings, nexion.workspace, nexion.tabs, nexion.git, nexion.editor, nexion.contextMenu, nexion.app, nexion.tools.
JavaScript API
Nexion injects the nexion object into each plugin's context with the following namespaces.
nexion.commands
register(id, callback)
nexion.commands.register("my-plugin.runBuild", function(args) {
var result = nexion.process.spawn("/usr/bin/make", ["build"]);
if (result.exitCode === 0) {
nexion.notifications.showInfo("Build succeeded!");
} else {
nexion.notifications.showError("Build failed: " + result.stderr);
}
});
// With explicit title (overrides manifest title)
nexion.commands.register("my-plugin.deploy", "Deploy to Production", function(args) {
// ...
});execute(id)
nexion.commands.execute("my-plugin.runBuild");nexion.tools
register(config)
Register toolbar buttons programmatically (same fields as manifest tools):
nexion.tools.register({
id: "my-plugin.deploy",
displayName: "Deploy",
systemIcon: "paperplane",
action: "command",
command: "my-plugin.deploy"
});nexion.notifications
showInfo(message), showWarning(message), showError(message)
nexion.notifications.showInfo("Operation completed.");
nexion.notifications.showWarning("File not found, using default.");
nexion.notifications.showError("Failed to connect.");show(config)
Show a modal dialog with a message:
nexion.notifications.show({
title: "Build Complete",
message: "42 files compiled successfully.",
level: "info" // "info", "warning", or "error"
});showInput(config, callback)
nexion.notifications.showInput({
title: "New Branch",
message: "Enter the branch name:",
placeholder: "feature/my-feature",
defaultValue: ""
}, function(name) {
if (name) {
nexion.process.spawn("git", ["checkout", "-b", name]);
}
});showTextarea(config, callback)
nexion.notifications.showTextarea({
title: "Release Notes",
message: "Enter release notes for this version:",
placeholder: "What changed...",
defaultValue: ""
}, function(notes) {
if (notes) {
nexion.data.writeFile("release-notes.md", notes);
}
});showPicker(config, callback)
// Simple string array
nexion.notifications.showPicker({
title: "Select Environment",
message: "Choose the deployment target:",
items: ["Development", "Staging", "Production"]
}, function(selected) {
if (selected) {
nexion.notifications.showInfo("Deploying to " + selected);
}
});
// Or with label/value pairs
nexion.notifications.showPicker({
title: "Select Branch",
message: "Choose a branch:",
items: [
{ label: "Main", value: "main" },
{ label: "Develop", value: "develop" }
]
}, function(selected) {
// selected is the value, not the label
});nexion.process
Requires process.spawn permission.
spawn(command, args, options)
var result = nexion.process.spawn("git", ["status", "--porcelain"]);
// result.stdout : standard output
// result.stderr : standard error
// result.exitCode: exit code (0 = success)
// With options
var result = nexion.process.spawn("npm", ["test"], {
cwd: "/path/to/project",
debug: true
});which(command)
var nodePath = nexion.process.which("node");
// "/usr/local/bin/node" or nullnexion.fs
Requires filesystem.read and/or filesystem.write. All paths are relative to the project root.
readFile(path), writeFile(path, content)
var content = nexion.fs.readFile("package.json");
var pkg = JSON.parse(content);
nexion.fs.writeFile("output.txt", "Build completed at " + new Date().toISOString());exists(path), cwd()
if (nexion.fs.exists("Makefile")) {
// ...
}
var root = nexion.fs.cwd();listDirectory(path)
var files = nexion.fs.listDirectory("src");
// Returns: [{ name: "index.js", isDirectory: false }, ...]stat(path)
var info = nexion.fs.stat("README.md");
// info.size : file size in bytes
// info.isFile : boolean
// info.isDirectory : boolean
// info.modifiedAt : last modified timestamp (epoch seconds)nexion.terminal
Requires terminal.send and/or terminal.read.
sendCommand(command), clear()
nexion.terminal.sendCommand("npm run build");
nexion.terminal.clear();runInFloatingWindow(command, title)
nexion.terminal.runInFloatingWindow("top", "System Monitor");onOutput(callback)
nexion.terminal.onOutput(function(text) {
if (text.indexOf("ERROR") !== -1) {
nexion.notifications.showError("Error detected!");
}
});nexion.data
Sandboxed per-plugin storage in ~/.nexion/Plugins-Data/<identifier>/. No permissions required.
// Get data directory path
var dataDir = nexion.data.path;
// Read/write files
nexion.data.writeFile("state.json", JSON.stringify({ lastRun: Date.now() }));
var raw = nexion.data.readFile("state.json");
// Check existence, list, delete
nexion.data.exists("cache.json");
nexion.data.listDirectory("");
nexion.data.remove("cache.json");nexion.settings
// Read settings declared in the manifest
var outputPath = nexion.settings.get("my-plugin.outputPath");
// Update a setting
nexion.settings.set("my-plugin.outputPath", "./dist");
// Listen for changes
nexion.settings.onDidChange("my-plugin.verbose", function(newValue) {
nexion.notifications.showInfo("Verbose: " + newValue);
});nexion.workspace
// Current workspace info
var root = nexion.workspace.rootPath;
var name = nexion.workspace.name;
// Lifecycle events
nexion.workspace.onDidOpen(function(path) {
nexion.notifications.showInfo("Opened: " + path);
});
nexion.workspace.onDidClose(function() {
// workspace closed
});
// SSH context
var isSSH = nexion.workspace.isSSH; // true if current tab is an SSH project
var host = nexion.workspace.sshHost; // e.g. "server.example.com" or ""
var user = nexion.workspace.sshUser; // e.g. "deploy" or ""nexion.tabs
Access tab metadata and subscribe to tab changes. No permissions required.
// Active tab info: { id, name, kind, isSSH, sshHost }
var tab = nexion.tabs.active;
if (tab && tab.isSSH) {
nexion.notifications.showInfo("SSH tab: " + tab.sshHost);
}
// All open tabs: [{ id, name, kind, isSSH }]
var allTabs = nexion.tabs.list;
// Subscribe to active tab changes
nexion.tabs.onDidChange(function(tab) {
// tab = { id, name, kind, isSSH, sshHost }
});nexion.git
Read-only access to the current project's git state. Updated automatically when the active tab changes or git status refreshes. No permissions required.
// Current state
var branch = nexion.git.branch; // e.g. "main"
var isRepo = nexion.git.isRepo; // true if inside a git repo
var stats = nexion.git.stats; // { staged: 2, modified: 5, untracked: 1 }
// Subscribe to git state changes
nexion.git.onDidChange(function(info) {
// info = { branch, isRepo, staged, modified, untracked }
if (info.modified > 0) {
nexion.ui.updateStatusItem("my-plugin.git", info.branch + " (" + info.modified + " modified)");
}
});nexion.editor
Access the current file selected in the Finder preview pane. No permissions required.
// Current editor state
var file = nexion.editor.activeFile; // full path or "" if no file selected
var mode = nexion.editor.mode; // "preview", "edit", or "diff"
// Subscribe to editor changes
nexion.editor.onDidChange(function(info) {
// info = { activeFile, mode }
});nexion.terminal (tmux)
The terminal API also exposes tmux session state for the focused pane. No permissions required for reading tmux state. In split-pane layouts, these values reflect the currently focused pane and update automatically when you switch between panes.
// Check if the focused pane is a tmux session
var isTmux = nexion.terminal.isTmux; // true if attached to tmux
var session = nexion.terminal.tmuxSession; // e.g. "dev" or ""
Note: nexion.workspace.isSSH is a tab-level property (all panes in an SSH tab are remote), while nexion.terminal.isTmux is per-pane (each pane may or may not be attached to a tmux session).
nexion.ui
Status bar items
nexion.ui.registerStatusItem({ id: "my-plugin.status", text: "Ready" });
nexion.ui.updateStatusItem("my-plugin.status", "Building...");
nexion.ui.removeStatusItem("my-plugin.status");Sidebar panels
// Register (use manifest sidebarPanels for declaration, this is programmatic)
nexion.ui.registerSidebarPanel({
id: "my-plugin.status",
title: "Build Status",
icon: "hammer",
scope: "project" // "global" (default) or "project"
});
// Update content (Markdown)
// For project-scoped panels, content is automatically stored per-project
nexion.ui.updateSidebarPanel("my-plugin.status",
"# Build Status\n\n**Status:** Running\n\n- 42 files processed"
);
// Switch to your panel
nexion.ui.selectSidebarPanel("my-plugin.status");nexion.contextMenu
register(id, callback)
Register a handler for a context menu item declared in the manifest:
nexion.contextMenu.register("my-plugin.openInEditor", function(args) {
// File context: args = { filePath: "/full/path/to/file" }
// Terminal context: args = { cwd: "/current/dir", selectedText: "..." }
if (args && args.filePath) {
nexion.process.spawn("code", [args.filePath]);
}
});nexion.app
refresh(target)
nexion.app.refresh("finder"); // Refresh the file tree
nexion.app.refresh("git"); // Refresh Git status
nexion.app.refresh("sidebar"); // Refresh sidebar panelsPlugin Lifecycle
- Discovery: Scans
~/.nexion/Plugins/and bundled plugins for.nexionpluginbundles. - Manifest parsing:
manifest.jsonis read and validated. Invalid manifests are skipped. - JSContext creation: An isolated JavaScriptCore context is created on a dedicated serial queue.
- API injection: The
nexion.*APIs are injected based on declared permissions. - main.js execution: Your entry point runs. Register commands, event listeners, and initialize state.
- Active: Declarative contributions (tools, keybindings, context menu, settings) are registered in the UI.
Installation
-
•
Manual: Drop the
.nexionpluginbundle into~/.nexion/Plugins/and restart Nexion or reload from Preferences. - • Preferences: Use the reload button in the Plugins preferences panel.
-
•
Double-click: Double-click a
.nexionpluginbundle in Finder.
Starting without plugins
Plugin JavaScript runs on a queue of its own, so a slow plugin cannot freeze the window by itself. It can still hold up a launch: a few bridge calls have to reach the main thread, a spawned process keeps its plugin's queue busy until it exits, and plugins are loaded before there is a window to click in. When a plugin is the reason Nexion will not start, Nexion is the one thing you cannot use to fix it.
A safe start discovers plugins as usual and does not activate any of them. There are four ways into it:
- • Shift: hold Shift, and nothing else, while Nexion launches.
-
•
Launch argument:
open -a Nexion --args --safe-mode. -
•
Environment:
NEXION_SAFE_MODE=1. - • By itself: when the previous start never finished, the next one comes up safe without being asked, since that is exactly the case where nobody can be expected to know a shortcut.
Nothing is switched off for good. Every plugin stays enabled and the next normal launch runs them all again, so a safe start is the moment to disable or remove the one at fault in Preferences > Plugins, where a banner says the start was a safe one. nexion version --json reports it as safeMode with the reason, and nexion plugin list shows an empty ACTIVE column: enabled is what you asked for, active is what is running.