-
Notifications
You must be signed in to change notification settings - Fork 15
Developer Guide Plugin Authoring
Full guide also available at
docs/plugin-authoring-guide.mdin the repository.
A complete guide to writing script plugins for YourSSH — no app rebuild required, just create a folder and write JS.
- How it works
- Plugin structure
- Manifest (plugin.json)
- API: Hook events
- API: Bridge functions
- API: Native panel messages
- Lifecycle & hot-reload
- Security & permissions
- Examples
- Debugging
- Known limitations
- Pre-publish checklist
YourSSH runs each plugin in an isolated JavaScript runtime (QuickJS). On startup the app scans ~/.yourssh/plugins/, loads each plugin, and injects a plugin object into the JS context.
Plugins register handlers via plugin.on(event, handler). The app fires events at the right moments (terminal data, session connect, etc.) — handlers are called synchronously or asynchronously depending on the event.
App (Dart) Plugin (JavaScript)
│ │
│──terminal data──► plugin.on("terminal.output", handler)
│◄──transformed data── return modifiedData
│
│──session opens──► plugin.on("session.connect", handler)
│ │
│──ssh.exec()────► ssh.exec(sessionId, "whoami")
│◄──result──────── Promise<{stdout, stderr, exitCode}>
~/.yourssh/plugins/
my-plugin/
plugin.json ← required: manifest
index.js ← required: entry point
lib/
helpers.js ← optional: helper files
A plugin is a directory inside ~/.yourssh/plugins/. The app identifies it via plugin.json. No install step, no build step.
{
"id": "dev.yourname.myplugin",
"name": "My Plugin",
"version": "1.0.0",
"entry": "index.js",
"minAppVersion": "1.0.0",
"permissions": [
"terminal.transform",
"session.observe"
]
}| Field | Description |
|---|---|
id |
Unique reverse-domain ID. Pattern: ^[a-z0-9][a-z0-9._\-]{0,63}$
|
name |
Display name shown in UI |
version |
Semantic version: MAJOR.MINOR.PATCH
|
entry |
JS entry point (relative to plugin folder) |
minAppVersion |
Minimum YourSSH version required |
permissions |
List of permissions needed (see section 8) |
- Must start with a lowercase letter or digit
- Allowed characters:
a-z,0-9,.,_,- - Maximum 64 characters
- Follow reverse-domain convention:
dev.yourname.pluginname
Plugins register handlers via plugin.on(event, handler).
Required permission:
terminal.transform(to modify) orterminal.read(observe-only)
plugin.on("terminal.output", function(ctx) {
// ctx.sessionId : string — SSH session ID
// ctx.data : string — raw terminal output text (may contain ANSI escape codes)
// Return string → replace data
// Return null / undefined → pass-through (no change)
return ctx.data.replace(/ERROR/g, "\x1b[31mERROR\x1b[0m");
});Hot path: Called for every chunk of data from the SSH server. Must be synchronous and fast (< 5ms). Do not perform I/O or heavy computation here.
Required permission:
terminal.intercept
plugin.on("terminal.input", function(ctx) {
// ctx.sessionId : string
// ctx.data : string — keystroke(s) about to be sent to the SSH server
// Return false → cancel (keystroke is not sent)
// Return string → modify and send that string instead
// Return null / undefined → pass-through
if (ctx.data.trim() === "rm -rf /") return false; // block
return ctx.data; // pass-through
});Hot path: Must be synchronous.
Required permission:
session.observe
plugin.on("session.connect", function(ctx) {
// ctx.sessionId : string
// ctx.host : string — hostname
// ctx.username : string
// ctx.port : number
console.log("Connected to " + ctx.username + "@" + ctx.host);
});Async handlers are allowed (handler does not block the terminal).
Required permission:
session.observe
plugin.on("session.disconnect", function(ctx) {
// ctx.sessionId : string
ui.statusbar.remove("my-status-" + ctx.sessionId);
});Required permission:
session.control
plugin.on("session.connect.before", function(ctx) {
if (ctx.host === "blocked-host.com") return false; // cancel connect
// return nothing → allow
});Required permission:
command.intercept
plugin.on("command.before", function(ctx) {
// ctx.sessionId : string
// ctx.command : string — command about to run
// Return false → cancel
// Return string → replace command
console.log("Running: " + ctx.command);
return ctx.command;
});Required permission:
command.intercept
plugin.on("command.after", function(ctx) {
// ctx.sessionId : string
// ctx.command : string — command that ran
// ctx.stdout : string
// ctx.stderr : string
// ctx.exitCode : number
if (ctx.exitCode !== 0) {
console.error("[plugin] Command failed: " + ctx.command);
}
});| Event | Permission | Sync | Can cancel | Can transform |
|---|---|---|---|---|
terminal.output |
terminal.transform |
✅ | ❌ | ✅ |
terminal.input |
terminal.intercept |
✅ | ✅ | ✅ |
session.connect |
session.observe |
❌ | ❌ | ❌ |
session.connect.before |
session.control |
✅ | ✅ | ❌ |
session.disconnect |
session.observe |
❌ | ❌ | ❌ |
command.before |
command.intercept |
✅ | ✅ | ✅ |
command.after |
command.intercept |
❌ | ❌ | ❌ |
Bridge functions let plugins call into the app. Available only when the corresponding permission is granted.
Permission:
session.observeorssh.exec
const sessions = ssh.sessions();
// Returns:
// [
// {
// sessionId: "abc123",
// host: "myserver.com",
// username: "ubuntu",
// port: 22,
// connected: true
// }
// ]Permission:
terminal.inject
// Sends the text directly into the active shell (as if the user typed it)
ssh.inject(sessionId, "ls -la\n"); // \n submits the commandPermission:
sftp.read(for list/read) orsftp.write(for write/delete/mkdir)
// List remote directory
const entries = await sftp.list(sessionId, "/var/log");
// entries: [{ name, isDir, size, modified }]
// Read remote file content
const content = await sftp.read(sessionId, "/etc/hostname");
// Write to remote file
await sftp.write(sessionId, "/tmp/test.txt", "hello world");
// Delete remote file
await sftp.delete(sessionId, "/tmp/test.txt");
// Create remote directory
await sftp.mkdir(sessionId, "/tmp/newdir");Permission: None — always available. Auto-namespaced by plugin id.
// Save
await storage.set("mykey", "myvalue");
// Load
const val = await storage.get("mykey");
if (val !== null) console.log(val.value);
// Delete
await storage.delete("mykey");Keys are automatically namespaced as plugin::<id>::storage::<key> — no collision risk with other plugins.
Permission:
ui.notify
ui.notify("Upload complete!", { type: "info" });
// type: "info" | "warning" | "error"Permission:
ui.statusbar
// Add item
ui.statusbar.add("my-item", {
label: "CPU: --",
tooltip: "Remote CPU usage"
});
// Update label
ui.statusbar.update("my-item", { label: "CPU: 42%" });
// Remove
ui.statusbar.remove("my-item");Status bar items appear at the bottom of the app window.
Permission:
ui.panel
ui.panel.register({
title: "My Panel",
icon: "monitor",
webviewEntry: "panel/index.html", // relative to plugin folder
onMessage: function(msg) {
if (msg.type === "get-data") {
return { type: "data", value: "hello" };
}
}
});The panel onMessage handler must be synchronous. For async SSH/SFTP operations from panel HTML, use native panel messages instead.
Permission:
ui.clipboard
ui.clipboard.copy(snippet.command);
ui.notify("Copied to clipboard", { type: "info" });Permission:
ui.statusbarorui.panel
ui.addCommand({
id: "clear-logs",
label: "Clear Remote Logs",
keybinding: "Ctrl+Shift+L" // optional
});The command appears in the command palette. Note: Command click handler is not yet implemented (see Known limitations).
Permission: None — always available.
console.log("debug message");
console.warn("warning");
console.error("error message");Logs appear in the Plugin Console (Settings → Script Plugins → plugin → Console). Multiple arguments are supported:
console.log("Sessions:", sessions.length, "connected");Plugin panel HTML can send native messages to perform SSH/SFTP operations without JS async limitations. Dart handles them directly and returns the result to the WebView.
Use via pluginBridge.send() from panel/index.html:
const r = await pluginBridge.send({
type: 'ssh-exec',
sessionId: 's1',
command: 'uname -a'
});
// r = { type: 'exec-result', stdout: '...', stderr: '', exitCode: 0 }
if (r.exitCode !== 0) console.error(r.stderr);
else display(r.stdout);const r = await pluginBridge.send({ type: 'ssh-sessions' });
// r = { type: 'sessions', data: [{ sessionId, host, username, port, connected }] }
const sessions = r.data;const r = await pluginBridge.send({
type: 'sftp-list',
sessionId: 's1',
path: '/var/log'
});
// r = { type: 'sftp-entries', data: [{ name, isDir, size, modified }] }
// OR { type: 'error', message: '...' }const r = await pluginBridge.send({
type: 'sftp-read',
sessionId: 's1',
path: '/etc/hostname'
});
// r = { type: 'sftp-content', content: '...' }Note: Native message types do not need to be declared in
plugin.jsonpermissions — they are handled by Dart, not the JS bridge. The plugin still needssession.observeto track session IDs.
App start
│
├── Scan ~/.yourssh/plugins/
├── Validate plugin.json
├── Check permissions (show consent dialog if not yet approved)
└── Execute index.js → plugin.on(...) registers handlers
The app watches for file changes. When a .js or plugin.json file changes:
- Unloads the old plugin (clears all handlers)
- Reloads and re-executes from scratch
No app restart needed. Just save the file and the plugin reloads immediately.
Top-level var myState = {} resets on plugin reload. Use storage.set/get to persist data across sessions and reloads.
The app shows a consent dialog listing all requested permissions. The user approves or denies each one. Plugins can only call bridge functions for permissions the user has approved.
| Permission | Grants access to |
|---|---|
terminal.read |
Observe terminal.output and terminal.input (read-only, return value ignored) |
terminal.transform |
Modify terminal output data |
terminal.intercept |
Cancel or modify user keystrokes before they reach SSH |
session.observe |
Receive session.connect / session.disconnect events |
session.control |
session.connect.before — can cancel a connection |
ssh.exec |
Call ssh.exec() to run commands on remote |
terminal.inject |
Send text directly into an active shell via ssh.inject()
|
sftp.read |
sftp.list(), sftp.read()
|
sftp.write |
sftp.write(), sftp.delete(), sftp.mkdir()
|
command.intercept |
command.before / command.after hooks for SSH exec commands |
ui.notify |
Show desktop notifications |
ui.statusbar |
Add items to the status bar |
ui.clipboard |
Write to the system clipboard |
ui.panel |
Register a sidebar panel with WebView UI |
Only request permissions that are actually needed. For example, a log highlighter plugin only needs terminal.transform — it does not need ssh.exec or sftp.write.
Highlights ERROR/WARN/INFO levels in terminal output.
plugin.json:
{
"id": "dev.example.log-highlighter",
"name": "Log Highlighter",
"version": "1.0.0",
"entry": "index.js",
"minAppVersion": "1.0.0",
"permissions": ["terminal.transform"]
}index.js:
plugin.on("terminal.output", function(ctx) {
var data = ctx.data;
data = data.replace(/\bERROR\b/g, "\x1b[31;1mERROR\x1b[0m");
data = data.replace(/\bWARN\b/g, "\x1b[33;1mWARN\x1b[0m");
data = data.replace(/\bINFO\b/g, "\x1b[36mINFO\x1b[0m");
return data;
});Shows remote server CPU usage in the status bar, updated every 10 seconds via panel HTML.
plugin.json:
{
"id": "dev.example.cpu-monitor",
"name": "CPU Monitor",
"version": "1.0.0",
"entry": "index.js",
"minAppVersion": "1.0.0",
"permissions": ["session.observe", "ui.statusbar", "ui.panel"]
}index.js:
plugin.on("session.connect", function(ctx) {
ui.statusbar.add("cpu-" + ctx.sessionId, {
label: "CPU: --",
tooltip: ctx.host + " CPU usage"
});
});
plugin.on("session.disconnect", function(ctx) {
ui.statusbar.remove("cpu-" + ctx.sessionId);
});
ui.panel.register({
title: "CPU Monitor",
icon: "monitor",
webviewEntry: "panel/index.html",
onMessage: function(msg) { return { type: "ok" }; }
});panel/index.html (simplified — poll via native message):
async function poll() {
const sessions = (await pluginBridge.send({ type: 'ssh-sessions' })).data;
if (!sessions.length) return;
const r = await pluginBridge.send({
type: 'ssh-exec',
sessionId: sessions[0].sessionId,
command: "top -bn1 | grep 'Cpu(s)' | awk '{print $2}'"
});
document.getElementById('cpu').textContent = 'CPU: ' + r.stdout.trim() + '%';
}
setInterval(poll, 10000);
poll();Automatically runs commands after connecting to a specific host.
plugin.json:
{
"id": "dev.example.auto-run",
"name": "Auto Run",
"version": "1.0.0",
"entry": "index.js",
"minAppVersion": "1.0.0",
"permissions": ["session.observe", "terminal.inject"]
}index.js:
var AUTO_COMMANDS = {
"prod-server.com": [
"cd /var/app && git log --oneline -5\n",
"systemctl status myapp --no-pager\n"
]
};
plugin.on("session.connect", function(ctx) {
var cmds = AUTO_COMMANDS[ctx.host];
if (!cmds) return;
for (var i = 0; i < cmds.length; i++) {
ssh.inject(ctx.sessionId, cmds[i]);
}
});Blocks dangerous patterns before the user submits them.
plugin.json:
{
"id": "dev.example.safety-guard",
"name": "Safety Guard",
"version": "1.0.0",
"entry": "index.js",
"minAppVersion": "1.0.0",
"permissions": ["command.intercept", "ui.notify"]
}index.js:
var BLOCKED = [/rm\s+-rf\s+\//, /dd\s+if=\/dev\/zero\s+of=\/dev\//, /mkfs\./];
plugin.on("command.before", function(ctx) {
for (var i = 0; i < BLOCKED.length; i++) {
if (BLOCKED[i].test(ctx.command)) {
ui.notify("Command blocked by Safety Guard", { type: "warning" });
return false; // cancel
}
}
return ctx.command;
});Saves per-host notes using the storage API and a WebView panel.
plugin.json:
{
"id": "dev.example.host-notes",
"name": "Host Notes",
"version": "1.0.0",
"entry": "index.js",
"minAppVersion": "1.0.0",
"permissions": ["session.observe", "ui.panel"]
}index.js:
ui.panel.register({
title: "Host Notes",
icon: "note",
webviewEntry: "panel/index.html",
onMessage: function(msg) {
if (msg.type === "save-note") {
storage.set("note-" + msg.host, msg.content);
return { type: "saved" };
}
if (msg.type === "load-note") {
var result = storage.get("note-" + msg.host);
return { type: "note", content: result ? result.value : "" };
}
}
});Settings → Script Plugins → [plugin name] → Console
All console.log() and console.error() output from the plugin appears here. JS runtime errors are also logged.
If a plugin throws an exception 5 or more times, the app shows a warning. At 10 exceptions, the plugin is automatically disabled.
To re-enable: go to Plugin Manager and save the file (hot-reload resets the error count).
| Error | Cause | Fix |
|---|---|---|
Plugin "x" does not have permission: ssh.exec |
Missing permission in manifest | Add it to the permissions array |
ManifestException: plugin.json missing required field: name |
Missing required field | Add the field to plugin.json |
QuickJsException: SyntaxError |
JS syntax error | Fix index.js |
| Plugin does not load | plugin.json cannot be parsed | Validate JSON at jsonlint.com |
# Create plugin directory
mkdir -p ~/.yourssh/plugins/test-plugin
# Write manifest
cat > ~/.yourssh/plugins/test-plugin/plugin.json << 'EOF'
{
"id": "dev.local.test",
"name": "Test",
"version": "1.0.0",
"entry": "index.js",
"minAppVersion": "1.0.0",
"permissions": ["terminal.transform"]
}
EOF
# Write plugin
cat > ~/.yourssh/plugins/test-plugin/index.js << 'EOF'
plugin.on("terminal.output", function(ctx) {
console.log("Got data: " + ctx.data.length + " bytes");
return ctx.data;
});
EOF
# Open app → consent dialog appears
# After approving, edit index.js → plugin reloads automatically| Limitation | Workaround |
|---|---|
ssh.exec() does not work inside JS hook handlers (session.connect, terminal.output, etc.) — the JS runtime is synchronous |
Use the native panel message ssh-exec from panel HTML instead |
setInterval / setTimeout are not available in the JS plugin context |
Use session.connect hook to trigger logic; timer-based polling must run from panel HTML (the browser has native timers) |
ui.addCommand click handler is not invoked — commands appear in the palette but clicking them is a no-op |
Known limitation — will be fixed in a future release |
sftp.write, sftp.delete, sftp.mkdir in JS are not yet implemented |
Use ssh.inject(sessionId, "rm file\n") to perform operations via the SSH shell |
Plugin panel WebView loads from file:// — some browser security policies may block fetch()
|
Use native panel messages (ssh-exec, sftp-read) instead of fetch() in panel HTML |
| Plugins cannot share state with each other | Use storage.set/get with a shared key prefix (there is no read-isolation between plugins) |
- ID follows reverse-domain format (
dev.yourname.pluginname) - Only request permissions that are actually needed (least privilege)
-
terminal.output/terminal.inputhandlers are synchronous and fast - Async operations use native panel messages, not JS hook handlers
- Persistent state uses
storage(not JS variables) - Cleanup in
session.disconnectif the plugin added statusbar items or timers - Tested with 0 active sessions (handlers do not crash when
ssh.sessions()returns empty) -
console.logdebug lines removed or reduced before publishing -
plugin.jsonis valid JSON -
README.mdin the plugin folder describes what the plugin does
~/.yourssh/plugins/
my-plugin/
plugin.json ← required: manifest
index.js ← required: entry point
README.md ← optional but recommended
lib/
utils.js ← optional helper modules
panel/
index.html ← optional WebView UI for ui.panel