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 resources

Manifest

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
textStatic description block (no key; uses content)
stringSingle-line text field
multilineMulti-line text editor
secretSecure (masked) input for tokens / API keys
numberNumeric input (int or double)
booleanToggle switch
selectDropdown; 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 null

nexion.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 panels

Plugin Lifecycle

  1. Discovery: Scans ~/.nexion/Plugins/ and bundled plugins for .nexionplugin bundles.
  2. Manifest parsing: manifest.json is read and validated. Invalid manifests are skipped.
  3. JSContext creation: An isolated JavaScriptCore context is created on a dedicated serial queue.
  4. API injection: The nexion.* APIs are injected based on declared permissions.
  5. main.js execution: Your entry point runs. Register commands, event listeners, and initialize state.
  6. Active: Declarative contributions (tools, keybindings, context menu, settings) are registered in the UI.

Installation

  • • Manual: Drop the .nexionplugin bundle 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 .nexionplugin bundle 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.