Plugins
Plugins are written in the same language as your config, so the line between configuring HUME and extending it is thin — a plugin is mostly just init.scm code that lives somewhere reusable.
The first half of this page covers using plugins other people wrote; Writing a plugin covers making your own.
Plugins are installed and updated by PLUM, a bundled plugin — see Core Plugins to enable it.
Installing a plugin
Add a declare-plugin or load-plugin call to your init.scm:
(declare-plugin "username/repo-name" #:commands '("my-cmd"))
(load-plugin "username/my-theme")Then run :plum-install to clone it from GitHub. PLUM never installs anything on its own, so nothing is fetched behind your back at startup; once the plugin is on disk, its commands and key bindings are available from the next launch.
See How plugins are loaded for the difference between the two verbs.
If a plugin supports configuration, pass it with #:config:
(load-plugin "core:vim-keybind" #:config (hash "change-to-eol" 'off))See Configuring a plugin for what a plugin does with this value, and the plugin's own docs for which keys it understands.
Plugin status
:plugin-statusShows all declared plugins, whether they loaded successfully, and which commands they registered.
Reloading configuration
:reload-configReloads init.scm from scratch. Useful after editing your config without restarting the editor.
How plugins are loaded
There are two ways to bring a plugin into the editor from init.scm:
| Verb | Timing |
|---|---|
(declare-plugin "name" #:commands ...) | Lazy — body deferred until first use |
(load-plugin "name") | Eager — body runs during startup |
Lazy plugins (declare-plugin) record a manifest of what the plugin offers, but don't evaluate the body until the first activation entry is exercised. This keeps startup fast, and is the recommended default: a language-server or formatting plugin whose commands you might never call costs nothing until you do.
Eager plugins (load-plugin) evaluate their body immediately. Use this for plugins that install options, hooks, or key bindings that must be in place from the first keystroke — themes, paste-style overrides, or anything without a natural "first use" trigger.
A lazy plugin needs at least one activation entry, or it could never activate. Declare them yourself:
#:commands— command names the plugin provides. HUME creates placeholder stubs so the names appear in:Tab completion immediately; the first dispatch triggers real definition.#:events— lifecycle hooks that trigger loading (e.g.,'on-buffer-open).#:languages— buffer language names that trigger loading.
...or, if the plugin ships its own defaults, leave all three off:
(declare-plugin "username/repo-name")A bare declare-plugin with no activation entries asks the plugin for its own defaults instead of erroring — see Default activation if you're writing a plugin and want to support this.
Writing a plugin
A plugin is a directory containing a plugin.scm — that file is the entry point HUME loads. For a plugin installed by PLUM, the directory is named after its GitHub owner and repo. The simplest plugin.scm:
(define-command! "hello"
"Print a greeting from my plugin."
(lambda ()
(log! 'info "Hello from my plugin!")))This registers :hello as a typed command.
Defining commands
(define-command! "command-name"
"One-line description shown in command help."
(lambda ()
...))Registers a typed command available as :command-name. The second argument is a doc string shown in command help; the function is called when the command is dispatched.
For commands that stream subprocess output to the terminal (installers, git operations), add the #:inline-output #t keyword. The alt-screen opens on the command's first real output — not eagerly at the start — so a run that produces no output (an already-up-to-date check, a validation error) never flashes an empty screen or waits on an unneeded keypress. Once something is printed, HUME waits for a keypress before returning to the editor, so the output stays on screen until you've read it.
Plugins run with the same privileges as HUME itself, so any Scheme process/filesystem function is available — there's no separate "shell builtin" layer. The one exception: inside an #:inline-output command, spawn subprocesses whose output should reach the terminal via run-inline-output! rather than a raw spawn-process/command call — it isolates the child into its own process group so a Ctrl+C meant to interrupt the subprocess doesn't kill HUME too, and it's the trigger that opens the alt-screen:
(define-command! "fetch-config"
"Clone the team config repo into the data directory."
(lambda ()
(run-inline-output! "git" (list "clone" "--"
"https://github.com/team/hume-config.git"
(path-join (data-dir) "config"))))
#:inline-output #t)For commands that should support dot-repeat (.), add #:repeatable #t. #:repeatable and #:inline-output are mutually exclusive:
(define-command! "delete-and-repeat"
"Delete the current selection; dot-repeatable."
(lambda ()
(call! "delete-selection"))
#:repeatable #t)Calling other commands
Use (call! ...) to dispatch other commands from within a plugin:
(define-command! "delete-and-deselect"
"Delete the selection, then collapse the cursor."
(lambda ()
(call! "delete-selection")
(call! "collapse-selection")))call! dispatches any command that can be bound to a key — built-in and Steel-defined alike — activating the target plugin on demand.
call! can't run : commands
Typed commands like write, quit, or edit are not reachable through call!. Calling one logs an error and does nothing, so (call! "write") will not save. Only key-bindable commands work here.
When forwarding a count argument to another command, a count of 0 means "as if no count was typed" — this is how move-down/move-up decide between visual-row and buffer-line movement, and it lets a key-bound command that forwards its own count behave the same way a native keybinding would.
Reading selections
(current-selections) returns the focused buffer's selections as a list of opaque (anchor head primary?) triples — char offsets, not grapheme ordinals. Don't index into the tuple directly; go through core:stdlib's helpers instead, which is what they're for:
(call! "stdlib/single-selection?" (current-selections))
(call! "stdlib/all-single-char?" (current-selections))
(call! "stdlib/cursor-char-index" (current-selections))(char-index->line idx) converts a char offset to a line number when you need one — it's a separate call rather than a field on every selection, since deriving it needs rope access a plain tuple doesn't have.
Depending on another plugin
WARNING
call! with an unknown command name logs an error and no-ops instead of aborting the command body — a missing plugin dependency shows up as an error in :messages, not as a crash, so check dependencies up front rather than relying on the error to be noticed.
If your plugin calls another plugin's commands via call!, check that the other plugin is available before you rely on it. Which check to use depends on when you need the dependency:
Needed immediately — your plugin body calls into the dependency at the top level, before any key is pressed. Check
(loaded-plugins): a lazily-declared plugin that hasn't activated yet won't show up in it, and there's no key press coming to trigger that activation.scheme(unless (member "core:stdlib" (loaded-plugins)) (error "my-plugin: requires core:stdlib — load it before my-plugin"))Needed later, inside a command — the dependency is only called from within a
lambdathat a key press fires.call!activates a lazily-declared plugin on demand, so it doesn't matter yet whether the dependency has activated — only that it was declared at all. Check(declared-plugins)instead:scheme(unless (member "core:stdlib" (declared-plugins)) (error "my-plugin: requires core:stdlib — declare or load it before my-plugin"))
Either check fails loudly at load time (startup or :reload-config), naming exactly what's missing, instead of leaving the bug to surface later at whatever moment the dependent command actually runs.
Pending character input
Some commands need a character argument from the user (like surround operations). (request-wait-char! cmd-name) dispatches cmd-name once the user types a character; (pending-char) then reads that char inside the dispatched command:
(define-command! "my-surround"
"Select the surrounding pair, then replace it with the next typed char."
(lambda ()
(call! "surround-paren")
(request-wait-char! "replace")))HUME shows nothing while it waits, so make it obvious from context that a character is expected.
Register prefix
To make subsequent (call! …) invocations in a command body target a specific register, call set-register-prefix! with a single-character register name (0–9, k, c, b — see Register prefix for what each one holds):
(define-command! "paste-kill-ring-after"
"Paste the kill-ring head after the selection (same as \"kp)."
(lambda ()
(set-register-prefix! "k")
(call! "paste-after")))The prefix persists for the rest of the command body.
Target the black hole register ("b") to discard a selection without touching the kill ring or clipboard — useful when a command needs to throw text away as a side effect of its own logic:
(define-command! "delete-without-clobbering"
"Delete the selection without overwriting the kill ring (same as \"bd)."
(lambda ()
(set-register-prefix! "b")
(call! "delete")))Hooks
Plugins react to editor lifecycle events by registering a hook handler with register-hook!. It must be called at the top level or inside a plugin body — not from a command body:
(register-hook! 'on-buffer-save
(lambda (buffer-id)
(log! 'info (string-append "saved buffer " (to-string buffer-id)))))Available hooks and their lambda signatures:
| Hook | Fires when | Lambda args |
|---|---|---|
on-buffer-open | A buffer is opened | (buffer-id) |
on-buffer-close | A buffer is about to close | (buffer-id) |
on-buffer-save | A buffer is saved | (buffer-id) |
on-mode-change | The editor mode changes | (old new) — mode strings |
on-language-set | A buffer's language is detected or changed | (buffer-id lang) — lang is a string or #f |
on-diagnostics-changed | A buffer's LSP diagnostics change | (buffer-id) — pull details with diagnostics-for-buffer |
on-lsp-attach | A language server attaches to a buffer | (buffer-id server-name) |
on-lsp-detach | A language server detaches from a buffer | (buffer-id server-name) |
on-viewport-change | The visible region of a pane changes | () |
on-trigger-char | A registered trigger character is typed | (buffer-id char source) |
on-completion-accept | A completion entry is accepted | (buffer-id item) |
on-completion-refilter | Completion input changes | (buffer-id text) |
on-buffer-open and on-buffer-close always fire as a pair for a given buffer: a buffer opened and closed within the same command never announces either one.
For lazy plugins, declare the events that should trigger activation via #:events on declare-plugin instead (see How plugins are loaded). LSP-related hooks like on-lsp-attach work fine with register-hook!, but can't be used as an #:events activation entry — a plugin gated only on on-lsp-attach never activates, since nothing attaches to a server until the plugin has already loaded and registered it.
set-option! can't be called from a hook handler: it's only valid while init.scm or a plugin body is being evaluated. Set options at the top level of your plugin instead.
Per-buffer overrides are available from a hook: (set-buffer-option! buffer-id "option" value) sets an option just on the buffer named by buffer-id, which works from hook and command bodies (see Buffer options for the list of settable options). Pass the buffer id the hook itself hands you rather than assuming the buffer you're editing — a hook can fire for a buffer other than the one you're currently focused on. language isn't an option; set it with set-buffer-language! instead.
A few more examples:
; format on save
(register-hook! 'on-buffer-save
(lambda (bid) (call! "lsp-fmt")))
; react to diagnostics
(register-hook! 'on-diagnostics-changed
(lambda (bid)
(let ((errs (diagnostics-for-buffer bid #:severity 'error)))
(log! 'info (string-append (to-string (length errs)) " errors")))))
; 2-space indentation for Markdown buffers
(register-hook! 'on-language-set
(lambda (bid lang)
(when (equal? lang "markdown")
(set-buffer-option! bid "tab-width" 2))))Default activation
If most users would activate your plugin the same way, give them a one-liner: put a declare-plugin call for your own plugin in a manifest.scm file next to your plugin's main file.
; manifest.scm
(declare-plugin "username/repo-name"
#:commands '("my-cmd" "my-other-cmd"))A user who writes (declare-plugin "username/repo-name") with no #:commands/#:events/#:languages gets your manifest's entries instead of an error. Passing any activation entry explicitly skips your manifest entirely — the user's list is authoritative, not merged with yours. A plugin with no manifest.scm can't be declared this way; users who want to use it lazily must list its activation entries themselves (or you can add one).
If your plugin reacts to a language but can't predict which ones a given user cares about, #:languages '("*") matches any buffer with a detected language:
; manifest.scm
(declare-plugin "username/repo-name"
#:languages '("*")
#:commands '("my-cmd"))#:config behaves the same as elsewhere: if the user passes #:config to their zero-argument declare-plugin, that value wins over anything your manifest passes — read it back the usual way with (plugin-config).
Keep manifest.scm to just the declare-plugin call — it runs whenever a user's bare declare-plugin resolves it, which is not a signal that your plugin is about to load.
Configuring a plugin
A plugin can read the #:config value its user passed to load-plugin or declare-plugin with (plugin-config). It returns whatever was passed — typically a hash — or an empty hash if nothing was passed:
(define cfg (plugin-config))
(unless (and (hash-contains? cfg "disable-binding") (hash-ref cfg "disable-binding"))
(bind-key! 'normal "C" "my-command"))Document the keys your plugin understands so users know what to pass.
The two verbs treat #:config differently: with declare-plugin the first declaration wins, so a later one can't quietly change it, while load-plugin always applies the config it's given. That means a bare (load-plugin "name") after a configured declare-plugin resets the plugin to its defaults.
Filesystem and processes
Plugins are trusted code: they can read and write any file, and spawn any process, just like any other Scheme program. There's no separate sandboxed subset of the filesystem — use Scheme's own functions directly (open-input-file, create-directory!, delete-file!, read-dir, path-exists?, and so on) for file access, and command/spawn-process/wait for running external tools.
A few extra functions cover things Scheme has no way to know on its own:
| Function | Description |
|---|---|
(data-dir) | HUME's data directory, or #f if unavailable |
(runtime-dir) | HUME's runtime directory, or #f if unavailable |
(path-join seg…) | Join path segments with the OS-native separator |
(json-parse str) | Decode a JSON string into hashmaps/lists/strings/numbers/booleans — errors on malformed input |
run-inline-output! also takes a #:cwd keyword to set the working directory, and raises an error if the command exits non-zero — wrap it in a handler if a failure is expected.
Only install or overwrite files under (data-dir) unless you have a specific reason to go elsewhere — that's where HUME expects a plugin's own data (installed grammars, downloaded servers, plugin state) to live.
Custom pickers
The modal fuzzy-finder panel behind Fuzzy Finder is a generic widget any plugin can drive — core:pickers' own file and buffer finders are built from nothing but this API.
(picker! items on-select #:prompt "buffers: ")items is a list of (display . payload) pairs — display is the string shown and matched against, payload is anything you like (a path, a buffer id, a hashmap); HUME never looks inside it. on-select fires exactly once: with the chosen item's payload if the user presses Enter, or #f if they press Esc, call picker-close!, or open a second picker while this one is still open (which replaces it).
For a handful of items — buffers, a plugin's own static list, the output of a quick synchronous command — build the whole list up front and pass it to picker! directly. For anything enumeration-scale (file lists, grep-style output), open the picker empty and stream an external command's output straight into it instead:
(define token (picker! '() (lambda (path) (when path (open-buffer! path))) #:prompt "files: "))
(picker-source-spawn! token "git" '("ls-files" "-z" "--cached" "--others" "--exclude-standard") #:nul #t)picker-source-spawn! runs cmd with args directly (no shell), splitting its stdout into lines (or NUL-delimited fields with #:nul #t) and appending each one to the picker as its own (line . line) item — display and payload are the same raw line. Nothing about the command's output passes through Scheme itself, so this stays fast even for tens of thousands of results; do any parsing of the selected line inside on-select, not up front. The child process is killed automatically if the picker is closed or replaced before the command finishes. (picker-push! token items) appends a batch of ordinary (display . payload) items instead, for a source that produces its own results asynchronously (an LSP request, a timer) rather than through a spawned command.
Bundled core plugins
HUME ships several built-in plugins — see Core Plugins for the full list and what each does.