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 declare which APIs they intend to use in their manifest. At runtime, calls to namespaces the plugin didn’t declare are rejected — captain.fs.write on a plugin without fileSystem.write returns a rejected Promise. Shell access has a second gate: even with the shell permission declared, the plugin only runs commands when Allow plugins to execute shell commands is checked in Settings → Plugins.
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"
});
}
}
Showcase Plugins
These official plugins demonstrate what's possible with the Captain's Deck plugin API. Download them to see real-world examples or use them as starting points for your own plugins.
Folder Stats
Instantly analyze any folder's contents with a single right-click. Folder Stats recursively scans directories to show:
- Total size of the folder
- File and folder counts
- Breakdown by file type
- The 10 largest files
Results are automatically copied to clipboard as a formatted report.
Captain's Toolkit
A power-user's Swiss army knife demonstrating the full plugin API. Features include:
- File analysis and metadata inspection
- Checksum calculation (MD5, SHA-1, SHA-256)
- Real-time file change monitoring
- Quick shell command execution
Adds a persistent status bar icon for quick access to all tools.
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
Distribute as a ZIP archive containing the plugin folder. Users install with Settings → Plugins → Install Plugin… and pick the ZIP — Captain’s Deck expands it into the Plugins folder, validates the manifest, and lists the plugin in Settings. The user enables it with the row’s checkbox.
For development, you can also work directly inside ~/Library/Application Support/CaptainsDeck/Plugins/your-plugin/ — the plugin shows up next launch.
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.jsonunderpermissions. - For
shell: also enable “Allow plugins to execute shell commands” in Settings → Plugins. Without that global toggle, even a properly-declaredcaptain.shell.execcall rejects. - For file system:
fileSystem.readandfileSystem.writeare separate. Reading a file with onlywritedeclared still fails.
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+