Overview
Captain's Deck plugins are written in JavaScript and run in a sandboxed environment. Plugins can:
- Add custom context menu items
- Read and write files
- Show notifications and dialogs
- Access the clipboard
- Make network requests
- Register custom file system providers
- Store persistent settings
Plugins request specific permissions in their manifest, and users must approve high-risk permissions before installation.
Getting Started
Plugin Location
Plugins are installed in:
~/Library/Application Support/CaptainsDeck/Plugins/
Each plugin lives in its own folder:
Minimal Plugin
Create a folder and two files:
manifest.json{
"name": "Hello World",
"id": "com.example.hello-world",
"version": "1.0.0",
"apiVersion": "1",
"main": "main.js",
"description": "A simple hello world plugin",
"permissions": ["notifications"]
}
main.js
// Called when the plugin is activated
exports.activate = function(context) {
console.log("Hello World plugin activated!");
captain.ui.notify({
title: "Hello!",
message: "Plugin loaded successfully"
});
};
// Called when the plugin is deactivated
exports.deactivate = function() {
console.log("Hello World plugin deactivated");
};
Plugin Structure
Lifecycle
- Discovery - Captain's Deck scans the Plugins folder for
manifest.jsonfiles - Validation - Manifests are parsed and validated
- Activation - Based on
activationEvents, the plugin'smain.jsis loaded - Runtime - Plugin responds to events and user actions
- Deactivation - Plugin is unloaded when disabled or app quits
Entry Points
Your main.js should export two functions:
// Required: Called when plugin activates
exports.activate = function(context) {
// Initialize your plugin here
// Register event handlers, context menus, etc.
};
// Optional: Called when plugin deactivates
exports.deactivate = function() {
// Clean up resources here
};
Manifest Reference
Required Fields
| Field | Type | Description |
|---|---|---|
name |
string | Display name of the plugin |
id |
string | Unique identifier (reverse domain notation) |
version |
string | Semantic version (e.g., "1.0.0") |
apiVersion |
string | API version to use (currently "1") |
main |
string | Entry point JavaScript file |
Optional Fields
| Field | Type | Description |
|---|---|---|
description |
string | Short description of the plugin |
author |
string | Author name or organization |
homepage |
string | URL to plugin homepage |
license |
string | License identifier (e.g., "MIT") |
icon |
string | Path to icon file (PNG, 128x128) |
Permissions
Declare required permissions in the permissions array:
| Permission | Description | Risk Level |
|---|---|---|
fileSystem.read |
Read files and directories | Low |
fileSystem.write |
Create, modify, delete files | High |
fileSystem.watch |
Watch for file changes | Low |
contextMenu |
Add context menu items | Low |
notifications |
Show system notifications | Low |
statusBar |
Display status bar items | Low |
dialogs |
Show dialogs and prompts | Low |
clipboard |
Access system clipboard | Medium |
network |
Make HTTP requests | High |
shell |
Execute shell commands | High |
providers |
Register file providers | High |
settings |
Read/modify app settings | Medium |
Activation Events
Control when your plugin loads with activationEvents:
| Event | Description |
|---|---|
* |
Always active (default) |
onStartup |
Activate when app starts |
onContextMenu |
Activate on right-click |
onFileOpen:*.ext |
Activate for specific file types |
onDirectoryOpen:/path/* |
Activate for specific directories |
onCommand:plugin.command |
Activate on command execution |
onProvider:scheme |
Activate for provider scheme |
Example:
{
"activationEvents": [
"onFileOpen:*.md",
"onFileOpen:*.txt",
"onContextMenu"
]
}
Contributions
Declare UI contributions in the contributes object:
{
"contributes": {
"contextMenus": [
{
"id": "myPlugin.compress",
"title": "Compress Files",
"when": "selectedCount > 0",
"group": "actions"
}
],
"commands": [
{
"id": "myPlugin.doSomething",
"title": "Do Something",
"category": "My Plugin"
}
],
"keybindings": [
{
"command": "myPlugin.doSomething",
"key": "ctrl+shift+d"
}
]
}
}
API Reference
All APIs are accessed through the global captain object.
File System (captain.fs)
Requires fileSystem.read and/or fileSystem.write permissions.
// Read file as base64
const data = await captain.fs.read("/path/to/file");
// Read file as text
const text = await captain.fs.readText("/path/to/file.txt");
// Write file (string or base64)
await captain.fs.write("/path/to/file.txt", "Hello World");
// Get file info
const info = await captain.fs.stat("/path/to/file");
// Returns: { name, path, isDirectory, size, modificationDate, creationDate, permissions }
// List directory
const files = await captain.fs.ls("/path/to/directory");
// Returns: [{ name, path, isDirectory, size }, ...]
// Check if path exists
const exists = await captain.fs.exists("/path/to/file");
// Create directory
await captain.fs.mkdir("/path/to/new/directory");
// Delete file or directory
await captain.fs.rm("/path/to/file");
User Interface (captain.ui)
// Show notification
captain.ui.notify({
title: "Operation Complete",
message: "Processed 42 files"
});
// Show quick pick (coming soon)
const choice = await captain.ui.showQuickPick([
{ label: "Option 1", description: "First option" },
{ label: "Option 2", description: "Second option" }
]);
// Show input box (coming soon)
const input = await captain.ui.showInputBox({
prompt: "Enter a name",
value: "default"
});
Context Menu (captain.contextMenu)
Requires contextMenu permission.
// Register a context menu handler
captain.contextMenu.register("myPlugin.compress", async (files) => {
// files = array of selected file items
for (const file of files) {
console.log(`Selected: ${file.name} at ${file.path}`);
console.log(` Size: ${file.size} bytes`);
console.log(` Is directory: ${file.isDirectory}`);
}
// Do something with the files...
});
Clipboard (captain.clipboard)
Requires clipboard permission.
// Read clipboard
const text = await captain.clipboard.read();
// Write to clipboard
await captain.clipboard.write("Hello from plugin!");
Storage (captain.storage)
Persistent key-value storage for your plugin.
// Save a value
captain.storage.set("myKey", { count: 42, name: "test" });
// Retrieve a value
const value = captain.storage.get("myKey");
// Returns: { count: 42, name: "test" }
// Remove a value
captain.storage.set("myKey", null);
Events (captain.events)
Subscribe to app events.
// Listen for an event
captain.events.on("fileSelected", (data) => {
console.log("File selected:", data.path);
});
// Stop listening
captain.events.off("fileSelected", handler);
// Emit custom event (for plugin-to-plugin communication)
captain.events.emit("myPlugin.customEvent", { foo: "bar" });
Available events:
fileSelected- User selected a filedirectoryChanged- Current directory changedpaneSwitched- Active pane changedthemeChanged- Theme was changed
Commands (captain.commands)
Register and execute commands.
// Register a command
captain.commands.register("myPlugin.sayHello", () => {
captain.ui.notify({ title: "Hello!", message: "Command executed" });
});
// Execute a command (yours or another plugin's)
captain.commands.execute("myPlugin.sayHello");
Console
Standard console methods are available for debugging:
console.log("Info message");
console.warn("Warning message");
console.error("Error message");
Output appears in Console.app under the CaptainsDeck process.
Examples
Context Menu: Open in VS Code
manifest.json{
"name": "Open in VS Code",
"id": "com.example.open-in-vscode",
"version": "1.0.0",
"apiVersion": "1",
"main": "main.js",
"permissions": ["contextMenu", "shell"],
"contributes": {
"contextMenus": [
{
"id": "vscode.openFile",
"title": "Open in VS Code",
"when": "selectedCount > 0"
}
]
}
}
main.js
exports.activate = function() {
captain.contextMenu.register("vscode.openFile", async (files) => {
for (const file of files) {
// Uses shell permission
await captain.shell.exec(`code "${file.path}"`);
}
});
};
File Processor: Markdown to HTML
manifest.json{
"name": "Markdown Converter",
"id": "com.example.markdown-converter",
"version": "1.0.0",
"apiVersion": "1",
"main": "main.js",
"permissions": ["contextMenu", "fileSystem.read", "fileSystem.write"],
"activationEvents": ["onFileOpen:*.md"],
"contributes": {
"contextMenus": [
{
"id": "markdown.convert",
"title": "Convert to HTML",
"when": "resourceExtname == .md"
}
]
}
}
main.js
exports.activate = function() {
captain.contextMenu.register("markdown.convert", async (files) => {
for (const file of files) {
if (!file.path.endsWith('.md')) continue;
// Read markdown
const markdown = await captain.fs.readText(file.path);
// Simple conversion (use a real library in production)
const html = simpleMarkdownToHtml(markdown);
// Write HTML file
const htmlPath = file.path.replace(/\.md$/, '.html');
await captain.fs.write(htmlPath, html);
captain.ui.notify({
title: "Converted",
message: `Created ${htmlPath}`
});
}
});
};
function simpleMarkdownToHtml(md) {
return md
.replace(/^### (.*$)/gm, '<h3>$1</h3>')
.replace(/^## (.*$)/gm, '<h2>$1</h2>')
.replace(/^# (.*$)/gm, '<h1>$1</h1>')
.replace(/\*\*(.*)\*\*/gm, '<strong>$1</strong>')
.replace(/\*(.*)\*/gm, '<em>$1</em>')
.replace(/\n/gm, '<br>');
}
Event Listener: Auto-Refresh
exports.activate = function() {
// React to directory changes
captain.events.on("directoryChanged", (data) => {
console.log(`Navigated to: ${data.path}`);
// Maybe fetch some data for this directory
checkForReadme(data.path);
});
};
async function checkForReadme(dirPath) {
const readmePath = dirPath + "/README.md";
if (await captain.fs.exists(readmePath)) {
captain.ui.notify({
title: "README Found",
message: "This directory has a README.md"
});
}
}
Best Practices
Performance
- Lazy activation - Use specific
activationEventsinstead of* - Batch operations - Process multiple files in one go
- Cache results - Use
captain.storageto cache expensive computations
Security
- Request minimal permissions - Only ask for what you need
- Validate input - Never trust file contents blindly
- Handle errors - Wrap async code in try/catch
exports.activate = function() {
captain.contextMenu.register("myPlugin.action", async (files) => {
try {
for (const file of files) {
await processFile(file);
}
} catch (error) {
console.error("Plugin error:", error);
captain.ui.notify({
title: "Error",
message: error.message
});
}
});
};
User Experience
- Provide feedback - Use notifications for long operations
- Respect preferences - Check user settings when available
- Fail gracefully - Show helpful error messages
Publishing
Plugin Package Structure
Distribution
Plugins can be distributed as:
- ZIP archive - Users extract to the Plugins folder
- Git repository - Users clone to the Plugins folder
- Plugin gallery - (Coming soon) Submit to the official gallery
Version Guidelines
- Use Semantic Versioning
- Increment MAJOR for breaking changes
- Increment MINOR for new features
- Increment PATCH for bug fixes
Troubleshooting
Plugin Not Loading
- Check manifest.json is valid JSON
- Verify the
iduses reverse domain notation (e.g.,com.example.plugin) - Ensure
mainpoints to an existing .js file - Check Console.app for error messages
Permission Denied
- Verify the permission is declared in manifest.json
- High-risk permissions require user approval on install
API Not Working
- Check you have the required permission
- Ensure you're using
awaitfor async methods - Check Console.app for JavaScript errors
Support
Plugin API Version 1 - Captain's Deck v1.0+