Writing a plugin
A plugin is a shared object that exports three C symbols and one C++ class. This guide covers the contract the server expects, the internals you need to reason about, a complete worked example for each of the three plugin types, how to wire it into CMake (in-tree or out-of-tree) and how to test it.
The big picture
The server (forge::Forge) never knows concrete rule classes. At start-up it dlopens every *.so in the plugins directory, asks each for its type, its name and a factory function, and files the factory under the name. Later, while parsing a rule file, every top-level key is looked up in that map: the factory creates a fresh instance, the instance parses its own settings, and the instance is stored in a forge::rules::Rules object that represents the file. At request time, Rules::trigger() and Rules::apply() walk those instances.
check). Requests are served sequentially on the server's single thread.The three plugin types
| Type | type() | Base class | Method to implement | When it runs | Shipped examples |
|---|---|---|---|---|---|
| trigger | TYPE_TRIGGER = 0 | forge::rules::ITrigger | bool trigger(const std::string& bin, const std::string& content) | Before anything else, on the untouched content. Any true among a file's triggers enables the file. | trigger (DefaultTrigger) |
| pre-rule | TYPE_PRE_RULE = 1 | forge::rules::IPreRule | void format(const std::string& bin, std::string& content) | On each block, before every rule of the file. | ansi (AnsiPreRule) |
| rule | TYPE_RULE = 2 | forge::rules::IRule | void format(const std::string& bin, std::string& content) | On each block, after the pre-rules, in file order. | ln, drop, dup, replace, insert |
IPreRule and IRule have the same signature; the split exists only to guarantee ordering: normalisation (strip colours, decode, unwrap) belongs to pre-rules, content edits to rules.
The ABI contract
The loader (forge::Forge::loadPlugin, src/forge/Forge-Loading.cpp) resolves exactly three unmangled symbols, so they must be inside extern "C":
extern "C" {
int type(void); // TYPE_TRIGGER (0) | TYPE_PRE_RULE (1) | TYPE_RULE (2) — from forge/Forge.hpp
const char* name(void); // the key users write in .cfg files; unique across all loaded plugins
T* factory(void); // T = forge::rules::ITrigger | IPreRule | IRule, matching type(); returns `new Concrete`
}
Rules enforced at load time (a violation rejects that .so only, with a message at -v debug):
type()must be 0, 1 or 2. The loader castsfactoryto the pointer type matchingtype()— a mismatch is undefined behaviour, not an error.name()must not collide with another loaded plugin, and must not beenableorblock(built-ins).- The object returned by
factory()is owned by the server and released withdeletethrough the interface's virtual destructor, so allocate it with plainnew.
The file name of the shared object is irrelevant (rule_ln.so, libwhatever.so, …): only the extension .so matters for discovery. In the repository each plugin is split in three source files, and the per-kind type() lives in a file shared by all plugins of that kind:
include/forge/rules/rules/LnRule.hpp ← class declaration
src/forge/rules/rules/lib.cpp ← int type() { return TYPE_RULE; } (shared by every rule)
src/forge/rules/rules/ln/lib.cpp ← name() + factory()
src/forge/rules/rules/ln/plugin.cpp ← load() + format() (also compiled into unit_tests)
The C++ interfaces
All interfaces live in namespace forge::rules, under include/forge/rules/. The hierarchy:
IInstruction / AInstruction — common to all types
class IInstruction: private utils::security::observer::Observer<"IInstruction"> {
public:
virtual std::string name(void) const = 0; // debug label printed by the server
virtual void load(const libconfig::Setting& s) = 0; // parse your settings; throw on invalid input
virtual ~IInstruction() = default;
// copy/move deleted
};
AInstruction implements name() from a string given to its constructor. You never derive from these two directly: derive from ITrigger, IPreRule or IRule, and pass your plugin name up the chain — LnRule(): IRule("ln") {}. The name you pass here is only used in logs; the name that matters for .cfg files is the one returned by the C function name(). Keep them identical to avoid confusion.
The private Observer base counts live instances per label; the test-suite uses it to detect leaks (AInstructionLeakTest). It needs nothing from you.
load(const libconfig::Setting& s)
s is the setting whose key is your plugin name, in one rule file. Its shape is entirely yours: a scalar (ansi = false;), an array (drop = [...]) or a group (ln = { ... };). Validate types explicitly — libconfig throws SettingTypeException on a bad cast, which is a less useful message — and report problems by throwing an ErrorException with the Rules external code and the setting path:
if (s["head"].getType() != libconfig::Setting::TypeInt) _unlikely {
throw utils::exception::ErrorException(
utils::exception::ExternalCode::Rules,
s["head"].getPath() + ": the head value must be an integer");
}
Any exception escaping load() rejects the whole rule file (the message is logged with -v debug and by check), other files are unaffected. Do expensive preparation here — compile regexes, build lookup tables — load() runs once per file at start-up.
Useful libconfig calls: s.exists("k"), s["k"], s.getType() (TypeInt, TypeInt64, TypeFloat, TypeString, TypeBoolean, TypeArray, TypeList, TypeGroup), s.isArray(), s.getLength(), s[i], static_cast<int>(s), static_cast<const char*>(s), s.lookupValue("k", var), s.getPath().
ITrigger::trigger(bin, content)
Return true to enable the file. bin is the program name as typed after exec -c; content is the full captured output, not yet block-split or formatted. Called for every request on every file that has triggers, so keep it cheap; the built-in short-circuits on the first true.
IPreRule::format / IRule::format(bin, content&)
Edit content in place. It holds one block (the whole output unless the file uses block). Blocks are joined afterwards with block.sep. Common line handling helper, copied verbatim in each shipped rule (there is no shared helper header yet):
static std::vector<std::string> splitLines(const std::string& content) {
std::vector<std::string> lines; std::size_t start = 0;
while (start <= content.size()) {
std::size_t end = content.find('\n', start);
if (end == std::string::npos) { lines.emplace_back(content.substr(start)); break; }
lines.emplace_back(content.substr(start, end - start)); start = end + 1;
}
return lines;
}
static std::string joinLines(const std::vector<std::string>& lines) {
std::string r; for (std::size_t i = 0; i < lines.size(); ++i) { r += lines[i]; if (i + 1 < lines.size()) r += '\n'; }
return r;
}
Note the convention: a trailing \n yields an empty last element, and joining restores it. Follow it so your rule composes with ln, drop and dup.
Lifecycle & guarantees
- One instance per (rule file, key). Two files using
indentget twoIndentRuleobjects with independent settings. Instances live as long as the server. - Single-threaded. The server handles requests one after another;
trigger()/format()are never called concurrently. You may keep mutable state, but remember the same instance sees every wrapped command. - Never throw from
trigger()orformat(). There is no catch around the request loop: an exception ends the server process (systemd restarts it 2 s later and the client that was waiting falls back to plain output). Catchstd::regex_errorand friends yourself. - Loading is best-effort. Unloadable
.sofiles and invalid.cfgfiles are skipped with a log line; make errors loud withcontext-forge check local … -v debug. - ABI. Your plugin and the server share vtables,
std::stringandlibconfig::Settingacross the.soboundary. Build with the same compiler (clang++), standard (C++20), standard library (libstdc++) and the headers of the exact server version you target.
Walkthrough: a rule plugin, step by step
We'll add indent, a rule that prefixes every line of a block with a string:
trigger = { bin = ["ls"]; };
indent = { by = " "; skip_empty = true; };
- Declare the class —
include/forge/rules/rules/IndentRule.hpp#ifndef INDENTRULE_H #define INDENTRULE_H #include "IRule.hpp" // forge::rules::IRule #include <libconfig.h++> // libconfig::Setting #include <string> // std::string namespace forge::rules { class IndentRule: public forge::rules::IRule { private: std::string _by = " "; bool _skipEmpty = false; public: void load(const libconfig::Setting& s) final; void format(const std::string& bin, std::string& content) final; IndentRule& operator=(const IndentRule&) = delete; IndentRule& operator=(IndentRule&&) = delete; IndentRule(): IRule("indent") {}; IndentRule(const IndentRule&) = delete; IndentRule(IndentRule&&) = delete; ~IndentRule() = default; }; } #endif - Implement
loadandformat—src/forge/rules/rules/indent/plugin.cpp#define _Exception // enable utils::exception::* (must precede the include) #define _Attribute // enable _hot / _unlikely / _unused … #include <utils/utils.hpp> #include "forge/rules/rules/IndentRule.hpp" #include <libconfig.h++> #include <string> void forge::rules::IndentRule::load(const libconfig::Setting& s) { if (!s.isGroup()) _unlikely { throw utils::exception::ErrorException(utils::exception::ExternalCode::Rules, s.getPath() + ": indent must be a group { by = \"...\"; skip_empty = bool; }"); } if (s.exists("by")) { if (s["by"].getType() != libconfig::Setting::TypeString) _unlikely { throw utils::exception::ErrorException(utils::exception::ExternalCode::Rules, s["by"].getPath() + ": the by value must be a string"); } this->_by = static_cast<const char*>(s["by"]); } if (s.exists("skip_empty")) { if (s["skip_empty"].getType() != libconfig::Setting::TypeBoolean) _unlikely { throw utils::exception::ErrorException(utils::exception::ExternalCode::Rules, s["skip_empty"].getPath() + ": the skip_empty value must be a boolean"); } this->_skipEmpty = static_cast<bool>(s["skip_empty"]); } } _hot void forge::rules::IndentRule::format(_unused const std::string& bin, std::string& content) { if (this->_by.empty()) return; std::string result; result.reserve(content.size() + this->_by.size() * 16); std::size_t start = 0; while (start <= content.size()) { std::size_t end = content.find('\n', start); const bool last = (end == std::string::npos); const std::size_t len = last ? content.size() - start : end - start; if (!(this->_skipEmpty && len == 0)) result += this->_by; result.append(content, start, len); if (last) break; result += '\n'; start = end + 1; } content = std::move(result); }Notice: validation with a path in the message, no exceptions in
format, work reserved up front, and_unusedonbinbecause the project compiles with-Wunused-parameter. - Export the C entry points —
src/forge/rules/rules/indent/lib.cpp#include "forge/rules/rules/IRule.hpp" #include "forge/rules/rules/IndentRule.hpp" extern "C" { const char* name(void) { return "indent"; } forge::rules::IRule* factory(void) { return new forge::rules::IndentRule; } }type()comes from the sharedsrc/forge/rules/rules/lib.cpp(return TYPE_RULE;) that CMake links into every rule plugin. For an out-of-tree build, add it to your ownlib.cpp(see below). - Register the target in
CMakeLists.txt(three edits, next to the existing rules):# 1. sources set(SRC_INDENT src/forge/rules/rules/indent/plugin.cpp src/forge/rules/rules/indent/lib.cpp ) add_library(rule_indent SHARED ${SRC_PLUGINS} ${SRC_RULES} ${SRC_INDENT}) # 2. output location (add rule_indent to the existing set_target_properties call for rules) set_target_properties( rule_ln rule_drop rule_dup rule_insert rule_replace rule_indent PROPERTIES PREFIX "" LIBRARY_OUTPUT_DIRECTORY ${CMAKE_SOURCE_DIR}/plugins/rules ) # 3. add it to the PLUGINS list so include dirs, link libraries, build modes and the `plugins` target apply set(PLUGINS trigger_default pre-rule_ansi rule_ln rule_drop rule_dup rule_insert rule_replace rule_indent ) - Build and validate
cmake --build build --target rule_indent # or: make ls plugins/rules/ # → … rule_indent.so mkdir -p /tmp/rules && printf 'trigger = { bin = ["ls"]; };\nindent = { by = " | "; };\n' > /tmp/rules/indent.cfg context-forge check local -R -P ./plugins -r /tmp/rules -v debug… indent: plugin successfully loaded … loaded plugins: 8 … loaded rules: 1 - Use it
context-forge setup --copy --recursive --plugins ./plugins --rules /tmp/rules context-forge exec -c ls| build | CMakeLists.txt | context-forge | …
Example: a trigger plugin
A trigger that fires when the output is longer than n bytes — useful to route only large outputs to a summarising rule set:
// include/forge/rules/triggers/SizeTrigger.hpp
class SizeTrigger: public forge::rules::ITrigger {
std::size_t _min = 0;
public:
void load(const libconfig::Setting& s) final;
bool trigger(const std::string& bin, const std::string& content) final;
SizeTrigger(): ITrigger("min_size") {};
/* deleted copy/move as usual */
};
// src/forge/rules/triggers/size/plugin.cpp
void forge::rules::SizeTrigger::load(const libconfig::Setting& s)
{
if (s.getType() != libconfig::Setting::TypeInt || static_cast<int>(s) < 0) _unlikely {
throw utils::exception::ErrorException(utils::exception::ExternalCode::Rules,
s.getPath() + ": min_size must be a non-negative integer");
}
this->_min = static_cast<std::size_t>(static_cast<int>(s));
}
_hot _nodiscard bool forge::rules::SizeTrigger::trigger(_unused const std::string& bin, const std::string& content)
{
return content.size() >= this->_min;
}
// src/forge/rules/triggers/size/lib.cpp
extern "C" {
const char* name(void) { return "min_size"; }
forge::rules::ITrigger* factory(void) { return new forge::rules::SizeTrigger; }
}
// type() = TYPE_TRIGGER comes from src/forge/rules/triggers/lib.cpp
CMake: add_library(trigger_size SHARED ${SRC_PLUGINS} ${SRC_TRIGGERS} ${SRC_SIZE}), output directory plugins/triggers. Because triggers are OR-ed, a file with both trigger = { bin = ["ls"]; } and min_size = 2048; applies to any ls output or any large output. Put them in separate files if you want AND semantics — or write a trigger that takes both criteria.
Example: a pre-rule plugin
A pre-rule that expands tabs to spaces so that later column-based regexes are stable:
class TabsPreRule: public forge::rules::IPreRule {
int _width = 4;
public:
void load(const libconfig::Setting& s) final {
if (s.getType() != libconfig::Setting::TypeInt || static_cast<int>(s) <= 0) _unlikely
throw utils::exception::ErrorException(utils::exception::ExternalCode::Rules, s.getPath() + ": tabs must be an integer > 0");
this->_width = static_cast<int>(s);
}
void format(_unused const std::string& bin, std::string& content) final {
std::string out; out.reserve(content.size()); int col = 0;
for (char c: content) {
if (c == '\t') { int n = this->_width - (col % this->_width); out.append(n, ' '); col += n; }
else { out += c; col = (c == '\n') ? 0 : col + 1; }
}
content = std::move(out);
}
TabsPreRule(): IPreRule("tabs") {};
};
// lib.cpp: name() → "tabs", factory() → new TabsPreRule; type() = TYPE_PRE_RULE from src/forge/rules/pre-rules/lib.cpp
CMake wiring, explained
The relevant pieces of the root CMakeLists.txt:
| Variable / call | Role |
|---|---|
SRC_PLUGINS | Sources shared by every plugin (currently empty; reserved for common helpers). |
SRC_TRIGGERS / SRC_PRE_RULES / SRC_RULES | The per-kind lib.cpp providing type(). |
add_library(<name> SHARED …) | One per plugin. Naming convention: trigger_*, pre-rule_*, rule_*. |
PREFIX "" + LIBRARY_OUTPUT_DIRECTORY | Drops the lib prefix and places the file under plugins/<kind>/ in the source tree. |
PLUGINS list | Every target in it gets the include dirs (include, include/exception), the link libraries (libconfig++, httplib, utils), the Debug/Asan/Optimized flags, a dependency on the generated exception header, and membership of the aggregate plugins target. |
tests/CMakeLists.txt → SRC_FORGE_PLUGINS | Add your plugin.cpp here to unit-test the class directly (do not add lib.cpp: several plugins define the same C symbols). |
Everything is compiled with -fPIC (CMAKE_POSITION_INDEPENDENT_CODE ON) and the warning set -Wall -Wextra -Wpedantic -Wshadow -Wunused-parameter. Keep your plugin warning-free; CI builds fail otherwise.
Out-of-tree plugins
You do not have to fork the repository. The headers under include/forge/rules/ plus libutils and libconfig++ are all a plugin needs. Minimal standalone project:
my-plugin/
├── CMakeLists.txt
├── IndentRule.hpp
├── plugin.cpp
└── lib.cpp ← contains type(), name() and factory()
cmake_minimum_required(VERSION 3.20)
project(cf-indent LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_COMPILER clang++) # same toolchain as the server
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
set(CONTEXT_FORGE_SRC "" CACHE PATH "Path to a context-forge checkout (for its headers)")
find_package(utils 2.13.22 CONFIG REQUIRED)
find_package(PkgConfig REQUIRED)
pkg_check_modules(LIBCONFIGXX REQUIRED IMPORTED_TARGET libconfig++)
add_library(rule_indent SHARED plugin.cpp lib.cpp)
set_target_properties(rule_indent PROPERTIES PREFIX "")
target_include_directories(rule_indent PRIVATE ${CONTEXT_FORGE_SRC}/include ${CMAKE_CURRENT_SOURCE_DIR})
target_link_libraries(rule_indent PRIVATE PkgConfig::LIBCONFIGXX utils::utils)
target_compile_options(rule_indent PRIVATE -Wall -Wextra -Wpedantic -Wshadow)
#include "forge/rules/rules/IRule.hpp"
#include "IndentRule.hpp"
extern "C" {
int type(void) { return 2; } // TYPE_RULE; or #include "forge/Forge.hpp" for the macro
const char* name(void) { return "indent"; }
forge::rules::IRule* factory(void) { return new forge::rules::IndentRule; }
}
cmake -S . -B build -DCONTEXT_FORGE_SRC=~/src/context-forge
cmake --build build
cp build/rule_indent.so ~/.config/context-forge/plugins/rules/ # or any directory you pass with --plugins
context-forge check dist -v debug && context-forge restart
Including forge/Forge.hpp for the TYPE_* macros pulls in httplib.h and Ollama.hpp; returning the literal keeps the plugin's dependencies to libutils and libconfig++. The values are part of the ABI and will not change without a major version bump.
Testing
The repository's tests (-DBUILD_TESTS=ON, GoogleTest, ctest in build/) compile the plugin classes straight into unit_tests, which means you can test load() and format() without any dlopen. Parse a config from a string and drive the object:
#define _Exception
#include <utils/utils.hpp>
#include "forge/rules/rules/IndentRule.hpp"
#include <gtest/gtest.h>
#include <libconfig.h++>
TEST(IndentRule, PrefixesEveryLine) {
libconfig::Config cfg;
ASSERT_NO_THROW(cfg.readString("indent = { by = \"> \"; };"));
forge::rules::IndentRule rule;
ASSERT_NO_THROW(rule.load(cfg.getRoot()["indent"]));
std::string content = "a\nb\n";
rule.format("ls", content);
EXPECT_EQ(content, "> a\n> b\n> "); // trailing newline → empty last line, also prefixed unless skip_empty
}
TEST(IndentRule, RejectsNonString) {
libconfig::Config cfg;
cfg.readString("indent = { by = 4; };");
forge::rules::IndentRule rule;
try { rule.load(cfg.getRoot()["indent"]); FAIL() << "expected a throw"; }
catch (const utils::exception::IException& e) {
EXPECT_EQ(e.getType(), utils::exception::Type::Error);
EXPECT_EQ(e.getCode(), static_cast<utils::exception::InternalCode>(utils::exception::ExternalCode::Rules));
}
}
Then in tests/CMakeLists.txt add ${CMAKE_SOURCE_DIR}/src/forge/rules/rules/indent/plugin.cpp to SRC_FORGE_PLUGINS and rules/IndentRule.cpp to SRC. The existing suites are good templates: tests/rules/Rules.cpp shows parameterised valid/invalid config cases, and tests/tools/MockInstruction.hpp provides MockTrigger/MockPreRule/MockRule if you need to test how your rule composes inside a Rules object.
For an end-to-end check of the C interface, context-forge check local -P <dir> -r <dir> -v debug is the fastest tool; the test binary also receives TESTS_PLUGINS_DIR (the built plugins/ directory) for tests that want to dlopen the real objects through utils::encapsulation::SharedObject.
libutils cheat-sheet
Everything the plugins use from libutils is opt-in: define the module macro before including <utils/utils.hpp>.
| Define | Gives you |
|---|---|
#define _Attribute | _hot, _cold, _nodiscard, _unused, _hidden, _likely/_unlikely (used after an if (…)), _fallthrough. |
#define _Exception | utils::exception::ErrorException, FatalException, IException and the generated ExternalCode enum: UnknownMode, MissingBinary, InvalidDirectory, Plugins, Rules, Ollama (from cmake/config/exceptions/global.json). Use Rules in load(). |
#define _Verbose | onBasicVerbose(expr), onAdvancedVerbose(expr), onDebugVerbose(expr) (stream-style: onDebugVerbose("n=" << n)), and the …C(std::cerr, …) variants. Respect the server's --verbose level, so use them freely in plugins for diagnostics. |
#define _IOManip | utils::iomanip::Char::ESC/BEL/DLE constants, colours (color_rgb), strong(), reset(). |
Server internals worth knowing
Request flow (Forge-Server.cpp, Forge-Client.cpp)
- The server creates a POSIX shared-memory segment named
context-forge:<server pid>with 10 channels × 4096 bytes (CHANNEL_NUMBER,CHANNEL_SIZEinForge.hpp). Each chunk carries a 4-byte index header; messages larger than one channel are split and reassembled by index on both sides. - The client payload is
bin+DLE(0x10) + captured text. The server splits on the firstDLE; sincebinis a program name and comes first, aDLEbyte inside the captured output is left untouched. formatCFGruns every loadedRulesin order (trigger, then apply),formatLLMruns afterwards if enabled. Plugins therefore see already-transformed text when several files match.
Loading (Forge-Loading.cpp)
- Plugins are loaded only if a
--rulesdirectory was given;--pluginswithout--rulesis ignored. - Directory iteration order is the filesystem's. Recursive mode uses
std::filesystem::recursive_directory_iterator. - A
Rulesobject keeps its source path (Rules::path()), which the server logs at-v advancedwhen the file applies — handy to see which file did what.
Exit / restart semantics
- The systemd unit uses
Restart=always,RestartSec=2. A plugin crash costs one request (the client falls back) and two seconds. Ollama::~Ollamacancels the keep-alive scheduler; the model unloads on Ollama's side after its ownkeep_alivewindow.
Checklist before shipping a plugin
- ☐
type(),name(),factory()areextern "C";factory()'s return type matchestype(). - ☐
name()is unique, notenable/block, and equals the string passed to the base constructor. - ☐
load()validates every setting's type and throwsErrorException(ExternalCode::Rules, path + ": …"). - ☐
trigger()/format()never throw and never block (no I/O, no network). - ☐ Regexes compiled in
load(), not per call. - ☐ Line handling keeps the trailing-newline convention (empty last element).
- ☐ Built with
clang++, C++20,-fPIC, warning-free, against the headers of the server version in use. - ☐
context-forge check local -R -P … -r … -v debugreports the plugin loaded and your sample.cfgaccepted. - ☐ Unit tests for valid/invalid configs and for the transformation, added to
tests/CMakeLists.txt.