context-forgev1.0.0
Developer guide

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.

Forge (server) your_plugin.so plugin instance Rules (one per .cfg) LOAD PLUGINS · for each *.so dlopen → int type() const char* name() T* (*factory)() ← stored under name LOAD RULES · for each *.cfg, for each top-level key factory() new YourRule instance->load(setting[key]) (throws → whole file rejected) rules.push(std::unique_ptr<T>(instance)) PER REQUEST · for each Rules if rules.trigger(bin, content) → rules.apply(bin, content) → trigger() / format() on your instance
Loading happens once at server start (or on check). Requests are served sequentially on the server's single thread.

The three plugin types

Typetype()Base classMethod to implementWhen it runsShipped examples
triggerTYPE_TRIGGER = 0forge::rules::ITriggerbool 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-ruleTYPE_PRE_RULE = 1forge::rules::IPreRulevoid format(const std::string& bin, std::string& content)On each block, before every rule of the file.ansi (AnsiPreRule)
ruleTYPE_RULE = 2forge::rules::IRulevoid 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):

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:

IInstructionname() = 0 · load(Setting) = 0 AInstructionname() final · AInstruction(name) ITriggertrigger(bin, content) = 0 IPreRuleformat(bin, content&) = 0 IRuleformat(bin, content&) = 0 DefaultTrigger · yours AnsiPreRule · yours LnRule DropRule … · yours : private Observer<"IInstruction"> (libutils)
Solid boxes are provided by context-forge; dashed boxes are what you write. Copy and move are deleted all the way down.

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

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; };
  1. 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
  2. Implement load and format — 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 _unused on bin because the project compiles with -Wunused-parameter.

  3. 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 shared src/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 own lib.cpp (see below).

  4. 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
    )
  5. 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
  6. 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 / callRole
SRC_PLUGINSSources shared by every plugin (currently empty; reserved for common helpers).
SRC_TRIGGERS / SRC_PRE_RULES / SRC_RULESThe per-kind lib.cpp providing type().
add_library(<name> SHARED …)One per plugin. Naming convention: trigger_*, pre-rule_*, rule_*.
PREFIX "" + LIBRARY_OUTPUT_DIRECTORYDrops the lib prefix and places the file under plugins/<kind>/ in the source tree.
PLUGINS listEvery 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_PLUGINSAdd 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>.

DefineGives you
#define _Attribute_hot, _cold, _nodiscard, _unused, _hidden, _likely/_unlikely (used after an if (…)), _fallthrough.
#define _Exceptionutils::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 _VerboseonBasicVerbose(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 _IOManiputils::iomanip::Char::ESC/BEL/DLE constants, colours (color_rgb), strong(), reset().

Server internals worth knowing

Request flow (Forge-Server.cpp, Forge-Client.cpp)

Loading (Forge-Loading.cpp)

Exit / restart semantics

Checklist before shipping a plugin