Rule files
A rule file is a libconfig document. Each top-level setting is either one of the two built-ins (enable, block) or the name of a loaded plugin, whose value configures one instance of that plugin. The server applies rule files in the order they were loaded, and inside a file in the order the settings appear.
Syntax essentials
name = value; // every setting ends with a semicolon
flag = true; // booleans: true / false
count = 42; // integers
text = "hello\n"; // strings: double quotes, C escapes
list = ["a", "b"]; // array (all elements same type)
group = { a = 1; b = "x"; }; // group of settings
// comments: //, # or /* */
Regular expressions are ECMAScript (std::regex). Remember to double the backslashes inside strings: "^total \\d+".
Files must end in .cfg and sit in the --rules directory (or below it with --recursive). A file that fails to parse or uses an unknown plugin name is reported (visible with -v debug or via check) and skipped; the others still load.
Evaluation order
For one rule file and one wrapped command, the server does the following:
- Trigger. Every
triggerinstance is askedtrigger(bin, content). If any says yes — or the file has no trigger at all — the file applies. Otherwise it is skipped. - Block split. If
blockis present, the content is cut into blocks (every n lines or every n characters), optionally keeping only the firstshowblocks. Otherwise the whole content is one block. - Pre-rules (
ansi) run on each block, in file order. - Rules (
ln,drop,dup,replace,insert, …) run on each block, in file order. - Blocks are joined back with
block.sep(default: nothing).
Within a file, pre-rules always run before rules regardless of where you write them; among rules (and among pre-rules), order is textual. Between files, order follows directory iteration — name your files 10-ansi.cfg, 20-ls.cfg if you need a deterministic sequence, and do not rely on cross-file ordering otherwise.
The same plugin name may appear only once per file (libconfig rejects duplicate keys). Use several files if you need, say, two replace passes.
enable built-in
enable = false;
Boolean. When false the file is ignored entirely. Default true.
trigger trigger
trigger = {
bin = ["ls", "exa"]; // optional: exact match on the wrapped program name
contains = "error"; // optional: regex_search on the whole output
match = "^\\s*$"; // optional: regex_match on the whole output
};
The file applies when any given condition holds: bin equals the wrapped program name (as typed after -c), contains finds a match anywhere in the output, or match matches the entire output. An empty group trigger = {}; always fires. The value must be a group; bin must be an array of strings.
ansi pre-rule
ansi = false; // strip ANSI escape sequences
Boolean, meaning "keep ANSI sequences". true is a no-op; false removes CSI sequences (ESC [ … final-byte, i.e. colours, cursor moves) and OSC sequences (ESC ] … BEL or ESC \, i.e. hyperlinks, window titles) so that the following regexes see plain text. Run it before line-based rules whenever the wrapped program colours its output — most do when they detect a terminal, and since context-forge captures through a pipe they usually won't, but tools forced with --color=always will.
block built-in
block = {
ln = 20; // split every 20 lines … or:
char = 4096; // split every 4096 characters (ln and char are mutually exclusive)
show = 3; // optional: keep only the first 3 blocks
sep = "\n---\n"; // optional: written between blocks when joining (default "")
};
All integers must be > 0. Each block goes through the pre-rules and rules independently, so ln = { head = 1; } after a block = { ln = 10; } keeps the first line of every 10-line chunk. show truncates before the rules run, which makes it a cheap way to cap huge outputs.
ln rule
ln = {
head = 10; // positive: keep the first 10 lines · negative: drop the first |n| lines
tail = -2; // positive: keep the last n lines · negative: drop the last |n| lines
};
Both optional; head is applied before tail. Lines are split on \n; the trailing newline of the original content counts as an empty last line, which is why tail = -1 often just removes that empty line.
drop rule
drop = ["^total \\d+", "^\\s*$"];
Array of regexes. Any line for which at least one pattern searches successfully is removed.
dup rule
dup = {
match = ["^Binary file .* matches$", "^warning:"]; // regex groups (regex_search per line)
eq = ["----"]; // exact-line groups
keep = 1; // how many occurrences of each group survive (default 1)
invert = false; // false: keep the first `keep` · true: keep the last `keep`
};
De-duplicates families of lines rather than identical lines: every pattern (and every eq string) defines a group; a line belongs to the first group that matches it; only keep members of each group are retained, first or last ones depending on invert. Lines matching no group are untouched.
replace rule
replace = {
match = ["\\bTODO\\b"]; // regexes, applied to the whole block, in order
eq = ["/home/you"]; // exact substrings, applied after the regexes
by = "[<INSERT>]"; // replacement; <INSERT> is substituted by the matched text
};
Every match is replaced by by. The literal token <INSERT> (first occurrence) inside by is replaced with the matched text, which lets you wrap or annotate rather than overwrite: by = "**<INSERT>**". Regex capture groups ($1) are not supported. by is required; use by = ""; to delete matches without removing whole lines (unlike drop).
insert rule
insert = {
before = "----- output -----\n";
after = "\n----- end -----";
};
Prepends and/or appends literal text to each block. Combined with block.sep this is enough to frame chunks for a model.
Complete examples
trigger = { bin = ["ls"]; };
drop = ["^total \\d+"];
// One trigger (bin + contains combined), one pre-rule (ansi),
// and every rule plugin chained on the same block.
// Order matters: drop -> dup -> replace -> ln -> insert.
trigger = {
bin = ["grep", "rg"];
contains = "match";
};
ansi = false; // strip colour codes before the rules run
drop = ["^\\s*$"]; // 1. drop empty lines
dup = { // 2. collapse repeated "Binary file ... matches" noise
match = ["^Binary file .* matches$"];
keep = 1;
invert = false;
};
replace = { // 3. flag TODOs inline
match = ["\\bTODO\\b"];
by = "[TODO -> <INSERT>]";
};
ln = { tail = 50; }; // 4. keep only the last 50 lines
insert = { // 5. wrap the whole output
before = "----- grep output -----\n";
after = "\n----- end -----\n";
};
trigger = { bin = ["cat", "journalctl"]; };
ansi = false;
block = {
ln = 25;
show = 4; // at most 100 lines get through
sep = "\n· · ·\n";
};
ln = { head = 24; }; // inside each block: drop the 25th line
Need behaviour none of these rules provide (JSON pretty-printing, column alignment, a per-program state…)? That's what plugins are for — a new rule is one class and three C functions.