Saltar a contenido

Writing Plugins

A Fern plugin is a directory inside the plugins folder that contains a manifest.json and a main.lua entry point. When Fern starts (and plugins are enabled) it reads each manifest.json and then executes each main.lua in lexicographic order. main.lua should register whatever commands, keybindings, events, or renderers the plugin provides. It is not called again after startup.

~/.config/fern/plugins/
  my-plugin/
    manifest.json
    main.lua

manifest.json

Every plugin should include a manifest.json at the root of its directory/repo. All fields are required. This file is used to help display pertinent details to users in Fern's UI.

Field Type Description
name string Unique plugin identifier (lowercase, hyphens allowed)
version string Semantic version string, e.g. "1.0.0"
description string Short description of what the plugin does
maintainer string Author name and email, e.g. "Jane Doe <jane@example.com>"
repo string URL of the plugin's source repository
{
  "name": "my-plugin",
  "version": "1.0.0",
  "description": "Does something useful in Fern.",
  "maintainer": "Jane Doe <jane@example.com>",
  "repo": "https://codeberg.org/jane/fern-my-plugin"
}

Plugins installed from the Fern-Terranium registry do not require a manifest.json in the tarball, as the registry already serves that metadata. For manually installed plugins, Fern requires a valid manifest.json exist with all fields present in order to load the plugin. So it's considered best practice to include that file in your project to ensure manual installations work without issues.


The fern global

All API functions are exposed on the fern global table. They are available from the moment main.lua begins executing.


Registration

These functions are called during startup (inside main.lua) to register behaviour.

fern.add_command(name, fn)

Registers a command that users can invoke from the command bar (:).

fern.add_command("reverse", function()
  local content = fern.get_content()
  if content then
    fern.set_content(content:reverse())
  end
end)

Conflict handling: Built-in command names (such as w, q, wq, note, Plugin, etc.) are reserved by Fern and cannot be overridden by plugins. If a plugin attempts to register a name that is already taken either by a built-in, or by another plugin that was loaded earlier, the registration is silently dropped. In this case, a startup warning is shown in the command bar. For now, the first registered handler will always wins. We will be actively working on a more elegant handling of conflicts for a future release.

fern.map(mode, key, fn)

Registers a keybinding for the given editor mode.

Mode When active
"normal" Normal / navigation mode
"insert" Insert mode
"visual" Visual selection mode
fern.map("normal", "ctrl+t", function()
  fern.notify("current vault: " .. fern.vault())
end)

Scope and conflict handling: App-level keys such as ctrl+q (quit), ctrl+s (save), palette key, command bar key, search key, help key, sidebar key, and all tab-management keys, are consumed by Fern before any plugin sees them and cannot be remapped by plugins. If two plugins register the same mode and key, the first one loaded wins and the second receives a startup warning. We will be actively working on a more elegant handling of conflicts for a future release.

fern.on(event, fn)

Registers a handler that fires when the named event occurs.

Event When fired
"open" A file is opened in the editor
"save" The active file is saved
"close" A file tab is closed
fern.on("save", function()
  fern.notify("saved!")
end)

fern.set_timer(seconds, fn)

Registers a function that fires repeatedly at the given interval (minimum 1 second).

fern.set_timer(60, function()
  fern.os_notify("Fern", "60 seconds have passed")
end)

fern.register_block_renderer(fence, fn)

Registers a custom renderer for fenced code blocks with the given fence name. The function receives the block content as a string and must return a Lua array of strings with one entry per display line.

fern.register_block_renderer("shout", function(content)
  local lines = {}
  for line in content:gmatch("[^\n]+") do
    lines[#lines + 1] = line:upper()
  end
  return lines
end)

A fenced block in a note with ```shout will be rendered using this function instead of the default code block style.


Content

fern.get_content()

Returns the full text of the currently open file as a string, or nil if no file is open.

fern.set_content(text)

Replaces the content of the currently open file with text. Takes effect after the current Lua call returns.

fern.notify(message)

Displays message in the command bar status area.

fern.list_files(kind)

Returns an array of stem names for all files of the given kind in the vault.

kind Files returned
"note" Notes
"journal" Journal entries
"template" Templates

fern.read_file(stem, kind)

Returns the text content of the file with the given stem and kind, or nil if no matching file is found.


Utilities

fern.now()

Returns a table describing the current local time.

Field Type Description
year number Four-digit year
month number Month (1-12)
day number Day of month (1-31)
hour number Hour (0-23)
min number Minute (0-59)
sec number Second (0-59)
iso string ISO 8601 datetime string, e.g. "2026-10-15T14:30:00"

fern.store_get(key)

Returns the string value stored under key for this plugin, or nil if the key has not been set. Each plugin has its own isolated key namespace backed by the vault's fern.db.

fern.store_set(key, value)

Stores value (a string) under key for this plugin.

fern.shell(command)

Runs command in a shell and returns its stdout as a string (trailing newline stripped). Returns an empty string if the command fails.

local branch = fern.shell("git -C " .. fern.vault() .. " rev-parse --abbrev-ref HEAD")

fern.os_notify(title, body)

Sends a native desktop notification. Uses osascript on macOS and notify-send on Linux. Runs asynchronously and does not block Fern.


Vault queries

fern.stems_for_tag(tag)

Returns an array of note stems that carry the given frontmatter tag.

local reading = fern.stems_for_tag("reading-list")

fern.frontmatter(stem, kind)

Returns a table of scalar frontmatter fields for the given stem, or nil if the file does not exist or has no frontmatter. kind defaults to "note".

local fm = fern.frontmatter("my-note")
if fm then
  fern.notify("title: " .. (fm.title or "untitled"))
end

fern.search(query)

Runs a full-text search against the vault and returns an array of result tables.

Field Description
stem Stem of the matching file
kind "note", "journal", or "template"
snippet Short excerpt showing the match

I/O and navigation

fern.read_file_path(path)

Reads an arbitrary file from the filesystem by absolute path. Returns its content as a string, or nil on error.

fern.json_decode(str)

Parses a JSON string and returns the equivalent Lua value (tables for objects and arrays, numbers, strings, booleans, or nil). Returns nil if the string is not valid JSON.

fern.vault()

Returns the absolute path to the current vault directory.

fern.open(stem, kind)

Opens the file with the given stem and kind in the editor.

fern.open("inbox", "note")

fern.pick(items, fn)

Opens Fern's picker UI populated with items and calls fn with the value the user selects. Each item may be either a plain string or a table with label and value fields.

local notes = fern.list_files("note")
fern.pick(notes, function(selected)
  fern.open(selected, "note")
end)

Example plugin

An example plugin can be found here: https://codeberg.org/bugwhisperer/fern-word-count/

Additionally, the Fern-Terranium registry lists all reviewed and approved Fern plugins: https://codeberg.org/InodeLabs/fern-terranium/