Plugin Development

Everything you need to know to create plugins for Captain's Deck. Extend functionality with JavaScript in a sandboxed environment.

Back to Guides

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:

Plugins/ ├── my-plugin/ │ ├── manifest.json │ └── main.js └── another-plugin/ ├── manifest.json └── main.js

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

  1. Discovery - Captain's Deck scans the Plugins folder for manifest.json files
  2. Validation - Manifests are parsed and validated
  3. Activation - Based on activationEvents, the plugin's main.js is loaded
  4. Runtime - Plugin responds to events and user actions
  5. 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 file
  • directoryChanged - Current directory changed
  • paneSwitched - Active pane changed
  • themeChanged - 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

  1. Lazy activation - Use specific activationEvents instead of *
  2. Batch operations - Process multiple files in one go
  3. Cache results - Use captain.storage to cache expensive computations

Security

  1. Request minimal permissions - Only ask for what you need
  2. Validate input - Never trust file contents blindly
  3. 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

  1. Provide feedback - Use notifications for long operations
  2. Respect preferences - Check user settings when available
  3. Fail gracefully - Show helpful error messages

Publishing

Plugin Package Structure

my-plugin/ ├── manifest.json # Required ├── main.js # Required ├── icon.png # Optional (128x128) ├── README.md # Recommended ├── LICENSE # Recommended └── lib/ # Optional additional JS files └── helpers.js

Distribution

Plugins can be distributed as:

  1. ZIP archive - Users extract to the Plugins folder
  2. Git repository - Users clone to the Plugins folder
  3. 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

  1. Check manifest.json is valid JSON
  2. Verify the id uses reverse domain notation (e.g., com.example.plugin)
  3. Ensure main points to an existing .js file
  4. Check Console.app for error messages

Permission Denied

  1. Verify the permission is declared in manifest.json
  2. High-risk permissions require user approval on install

API Not Working

  1. Check you have the required permission
  2. Ensure you're using await for async methods
  3. Check Console.app for JavaScript errors

Support

Plugin API Version 1 - Captain's Deck v1.0+