Quipu / Extensions

Build extensions
for Quipu

A plugin is a directory with a manifest.json and an index.js. Plugins can add custom file viewers, sidebar panels, commands, and keyboard shortcuts — all exposed through a single typed API.

Quick start

A minimal plugin has three files. This example adds a viewer that renders .csv files as a simple HTML table.

my-csv-viewer/manifest.json json
{
  "id": "my-csv-viewer",
  "name": "CSV Viewer",
  "version": "1.0.0",
  "main": "index.js"
}
my-csv-viewer/index.js javascript
// React is provided by Quipu — do not bundle it.
const { createElement: h, useState } = React;

function CsvViewer({ activeFile }) {
  const [rows, setRows] = useState([]);

  React.useEffect(() => {
    if (!activeFile) return;
    fetch(`/api/file?path=${activeFile.path}`)
      .then(r => r.text())
      .then(text => setRows(text.trim().split('\n').map(r => r.split(','))));
  }, [activeFile]);

  return h('table', { style: { padding: '16px' } },
    rows.map((cols, i) => h('tr', { key: i },
      cols.map((c, j) => h('td', { key: j }, c))
    ))
  );
}

// Every plugin must export an `init` function.
export function init(api) {
  api.register({
    id: 'csv-viewer',
    canHandle: (tab) => tab.name?.endsWith('.csv'),
    priority: 50,
    component: CsvViewer,
  });
}

Drop the directory into ~/.quipu/plugins/ and relaunch Quipu. Quipu loads every plugin at startup and calls init(api) with the full plugin API.

Manifest

Every plugin directory must contain a manifest.json at its root. Quipu reads this file before loading the plugin to validate structure and resolve the entry point.

manifest.json json
{
  "id":          "my-plugin",         // unique, kebab-case
  "name":        "My Plugin",         // display name
  "version":     "1.0.0",             // semver
  "main":        "index.js",          // entry point (relative to plugin dir)
  "description": "Optional blurb",    // shown in UI (future)
  "author":      "you"               // optional
}
Field Required Description
id required Globally unique plugin identifier. Use kebab-case. Quipu uses this as the namespace for all registrations from this plugin.
name required Human-readable display name shown in error messages and (future) plugin manager UI.
version required Semver version string. Used for display and conflict reporting.
main required Path to the plugin's JS entry point, relative to the plugin directory. Must be an ES module or IIFE that exports an init function.
description optional Short description of what the plugin does.
author optional Author name or email.
Validation: Quipu rejects plugins with missing id, name, version, or main fields. Failed plugins show a warning toast at startup and are skipped.

Plugin API (init)

Every plugin must export a named function init(api). Quipu calls it once at startup, passing a scoped PluginApi object that exposes all registration methods and services.

index.js javascript
/**
 * @param {import('quipu').PluginApi} api
 */
export function init(api) {
  // api.register(descriptor)        — register a file viewer
  // api.registerPanel(descriptor)   — add a sidebar panel
  // api.registerCommand(id, fn, opts)— add a command palette entry
  // api.executeCommand(id, ...args)  — trigger any registered command
  // api.registerKeybinding(entry)    — bind a key combo to a command
  // api.services.*                   — file system, git, terminal, kernel
  // api.version                      — current Quipu version string
}
TypeScript: If you build with TypeScript, install the quipu package (coming soon) for full PluginApi typings. Until then, use JSDoc @param {import('quipu').PluginApi} api or copy the interface from src/types/plugin-types.ts in the Quipu repo.

Viewer extensions

A viewer replaces the default TipTap editor for specific file types. Register one with api.register(descriptor). Quipu picks the viewer with the highest priority that returns true from canHandle(tab).

ExtensionDescriptor

FieldRequiredDescription
id required Unique viewer ID. Collisions are logged and the later registration wins.
canHandle required (tab: Tab) => boolean. Return true to claim the tab. Test tab.name, tab.path, or tab.type.
priority required Higher numbers win. Built-in viewers use 1090. Use 50 as a safe default for plugins.
component required React component that receives { tab, activeFile, onContentChange, isActive, workspacePath, showToast } as props.
commands optional Array of commands shown in the menu bar when this viewer is active. Each entry: { id, label, handler }.
onSave optional (tab) => void — called when the user triggers save (Ctrl/Cmd+S) while this viewer is active.
index.js — register a viewer javascript
export function init(api) {
  api.register({
    id: 'my-csv-viewer',

    // Claim any .csv file
    canHandle: (tab) => tab.name?.endsWith('.csv') ?? false,

    priority: 50,

    component: CsvViewer, // your React component

    // Optional: toolbar commands shown when this viewer is active
    commands: [
      {
        id: 'csv.export',
        label: 'Export as JSON',
        handler: () => { /* ... */ },
      },
    ],
  });
}
React is provided by Quipu. Do not bundle React in your plugin. Use React, ReactDOM, and ReactDOMClient as globals — they are already on window when your plugin loads. See the Building section for the Vite config that externalizes them correctly.

Sidebar panels

Panels appear in the activity bar on the left. Register one with api.registerPanel(descriptor) and it will appear alongside the built-in Explorer, Search, and Source Control panels.

PanelDescriptor

FieldRequiredDescription
id required Unique panel ID used to toggle visibility.
label required Tooltip shown on hover in the activity bar.
icon required Phosphor icon name with Icon suffix, e.g. "BracketsCurlyIcon". Falls back to a circle if not found.
component required React component rendered in the sidebar when this panel is active. Receives no props by default.
order optional Sort order in the activity bar. Built-ins use 0, 1, 2. Use 10+ for plugins.
badge optional () => number | null. Called on each render. Return a positive integer to show a badge, or null to hide it.
index.js — register a panel javascript
export function init(api) {
  api.registerPanel({
    id:        'my-panel',
    label:     'My Panel',
    icon:      'BracketsCurlyIcon',
    component: MyPanelComponent,
    order:     10,
  });
}

Quipu renders your component inside the sidebar container when the user clicks your icon in the activity bar. The component is mounted/unmounted as the panel is toggled; use useEffect to fetch data on mount.

Commands

Commands are named actions that appear in the command palette (Ctrl/Cmd+Shift+P). Register one with api.registerCommand(id, handler, options). Call any registered command programmatically with api.executeCommand(id, ...args).

index.js — register & execute commands javascript
export function init(api) {
  // Register a command that appears in the command palette
  api.registerCommand(
    'my-plugin.sayHello',           // command ID (namespaced by convention)
    (name = 'world') => {
      alert(`Hello, ${name}!`);
    },
    {
      label:    'Say Hello',              // shown in command palette
      category: 'My Plugin',            // group label
      shortcut: 'Ctrl+Shift+H',          // display-only hint (wire up separately)
    }
  );

  // Call another command (built-in or plugin)
  api.registerCommand('my-plugin.openSearch', () => {
    api.executeCommand('view.search');
  }, { label: 'Open Search', category: 'My Plugin' });
}

Built-in command IDs

Use these with api.executeCommand(id) to trigger core app actions:

Command IDDescription
file.saveSave the active tab.
file.closeTabClose the active tab.
file.reloadFromDiskReload the active tab from disk.
view.toggleSidebarShow / hide the sidebar.
view.searchOpen the Search panel.
view.quickOpenOpen Quick Open (Ctrl+P).
view.commandPaletteOpen the command palette.
terminal.toggleShow / hide the terminal.
terminal.newOpen a new terminal tab.
terminal.sendSend the active file to the FRAME skill.
terminal.claudeSend the active file to Claude in the terminal.
diff.openOpen the diff overlay. Pass { filePath, diffText, isStaged } as the first argument.
diff.closeClose the diff overlay.
editor.findToggle the in-editor find bar.
tab.nextSwitch to the next tab.
tab.prevSwitch to the previous tab.

Keybindings

Bind a keyboard shortcut to any registered command using api.registerKeybinding(entry). Built-in shortcuts are always registered first and take precedence over plugin shortcuts on conflict.

index.js — register a keybinding javascript
export function init(api) {
  api.registerCommand('my-plugin.sayHello', () => alert('Hello!'), {
    label:    'Say Hello',
    category: 'My Plugin',
  });

  api.registerKeybinding({
    key:       'ctrl+shift+h',       // Windows / Linux
    mac:       'cmd+shift+h',        // macOS (optional — falls back to key)
    commandId: 'my-plugin.sayHello',
  });
}

Key string format

Key strings are lowercase modifier(s) joined by +, followed by the lowercased KeyboardEvent.key value. Modifiers must appear in this order: ctrl, cmd, shift, alt.

ExampleMatches
ctrl+sCtrl+S on Windows/Linux
cmd+sCmd+S on macOS
ctrl+shift+kCtrl+Shift+K
ctrl+`Ctrl+Backtick
ctrl+tabCtrl+Tab
ctrl+shift+enterCtrl+Shift+Enter
Conflicts: If your keybinding matches a built-in, the built-in always wins. Quipu registers built-in shortcuts before plugins load. Choose a unique combo to avoid silent conflicts.

Services

api.services exposes the same dual-runtime services the app uses internally. Each service works in both Electron and browser mode without any changes to your plugin code.

api.services.fileSystem
Read, write, create, and delete files. Also provides openFileDialog() for native file pickers in Electron.
api.services.gitService
Stage, unstage, commit, fetch, and read git status for the current workspace. Returns structured diff and status objects.
api.services.terminalService
Send text to the active terminal, create new terminal tabs, and check if a Claude session is running.
api.services.kernelService
Start, interrupt, and communicate with Jupyter kernels for notebook execution.
index.js — using the file system service javascript
export function init(api) {
  const { fileSystem } = api.services;

  api.registerCommand('my-plugin.showFileSize', async () => {
    const content = await fileSystem.readFile('/path/to/file.txt');
    alert(`File is ${content.length} bytes`);
  }, { label: 'Show File Size', category: 'My Plugin' });
}
Tip: The active file path is available as activeFile.path inside viewer components. Pass it to service calls to operate on whatever the user currently has open.

Building

For plugins larger than a single file, use Vite (or any bundler) to compile your source into a single index.js. The critical requirement is that React and ReactDOM are externalized — Quipu provides them at runtime as globals.

vite.config.js javascript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

export default defineConfig({
  plugins: [react()],
  build: {
    lib: {
      entry:   'src/index.tsx',
      formats: ['es'],          // ES module output
      fileName: () => 'index.js',
    },
    rollupOptions: {
      // Externalize React — Quipu provides these as window globals
      external: ['react', 'react-dom', 'react-dom/client'],
      output: {
        globals: {
          'react':            'React',
          'react-dom':        'ReactDOM',
          'react-dom/client': 'ReactDOMClient',
        },
      },
    },
  },
});
package.json json
{
  "scripts": {
    "build": "vite build",
    "dev":   "vite build --watch"
  },
  "devDependencies": {
    "vite":                "^6.0.0",
    "@vitejs/plugin-react": "^4.0.0",
    "react":               "^19.0.0",
    "@types/react":        "^19.0.0"
  }
}

Run npm run build — the compiled index.js goes to dist/. Copy dist/index.js and manifest.json into your plugin directory.

JSX without a build step: For simple plugins, skip the bundler entirely. Use React.createElement directly (or the h alias shown in the quick-start example) and write plain .js. Quipu loads any ES module.

Installing

Quipu looks for plugins in a fixed directory at startup. Drop your plugin folder there and relaunch — no package manager or registry required.

  • 1
    Create the plugins directory if it does not exist:

    mkdir -p ~/.quipu/plugins
  • 2
    Copy your plugin directory into it:

    cp -r my-csv-viewer ~/.quipu/plugins/
  • 3
    Confirm the layout:

    ~/.quipu/plugins/
    └── my-csv-viewer/
        ├── manifest.json
        └── index.js
  • 4
    Relaunch Quipu. Plugins are loaded once at startup. If a plugin fails validation or throws in init(), Quipu shows a warning toast and continues loading other plugins.
Development workflow: Use npm run dev (Vite watch mode) in your plugin directory, then restart Quipu to pick up changes. A hot-reload mechanism is planned for a future release.