kbool adds what you expect from a high-level language to bash, and takes away none of what makes bash worth using: it is still a plain script, with no compiler, no runtime to install and no interpreter to ship. You source one file, and pipes, set -x and your ordinary commands work as before. Repo: https://github.com/Keksov/kbool (MIT, bash 5.1+). It was developed with AI assistance (I make the design decisions and review what goes in; the process is in the README).
The main part is kcl, the class library: 29 units, 39 classes and about 780 public methods (a class's own members, inherited ones not counted), plus about 150 properties. Most of it is not a new API made up for this project but a port of the Free Pascal and Delphi runtime libraries: interfaces that have been in daily use for years (TStringList since Delphi 1 in 1995) and have long been stable. The names, the behaviour and the edge cases come from a documented original, not from invention:
Collections: TList, TStringList (sorting, duplicate policies, case-insensitive search), TObjectList, TDictionary / TObjectDictionary (24 methods: TryGetValue, GetValueDef, ForEach, KeysToArray, change notifications), THashSet (union, intersection, difference), TQueue / TStack and their owning variants, TArray (sort, binary search, min / max with a comparator)
Text: TStringHelper (57 methods: trim, split, pad, startsWith, indexOfAny, …), TRegEx (isMatch, match with groups, matches, split, replace with a callback)
Dates and numbers: DateUtils (185 methods: encode / decode, incMonth, daysBetween, week numbers, ISO 8601), Math (123 methods, including floating point, on any awk), TStopwatch
Files: TPath (34), TFile (38: read / write / append, times, attributes, encrypt with HMAC), TDirectory (29), TIniFile / TMemIniFile
Tools: TGrep, TSed, TAwk, TFind, THead, TTail (typed options instead of hand-assembled argv, results into arrays or callbacks in the same shell), TPipe (a stream into a callback, with the producer's exit status kept)
Applications: TCustomApplication (an option parser ported line by line from FPC); there is also a small HTTP server, but it is a toy built to show off the class layer (one request at a time, about 10 requests per second), not something to put on a network
A small report: the top URLs of an access log, with settings from an INI file:
source kbool/kbool.sh || exit
kk.uses tinifile tfile tregex tdictionary tstringlist || exit # five units, each loaded once
# INI parsing without a hand-written sed/awk parser. A missing file, section or
# key gives the default, and so does "top = abc": ReadInteger never hands you a
# non-number to put into $(( )).
TMemIniFile.new cfg report.ini
cfg.ReadString report log access.log; log=$RESULT
cfg.ReadInteger report top 3; top=$RESULT
# The whole file into an array in one call, no $(cat) and no while-read loop.
tfile.readAllLines "$log" lines || exit
TDictionary.new hits # a counter map with an API, not a bare declare -A
for line in "${lines[@]}"; do
# A regex with capture groups that reads as one; the same pattern works with
# glibc, musl, BSD, macOS and cygwin, and nothing forks.
TRegEx.match "$line" '"[A-Z]+ ([^ ]+)' || continue
url=${RESULT_GROUPS[0]}
hits.GetValueDef "$url" 0 # the count so far, or 0 for a new URL
hits.AddOrSetValue "$url" $(( RESULT + 1 ))
done
TStringList.new report
add_row() { printf -v row '%6d %s' "$2" "$1"; report.Add "$row"; }
hits.ForEach add_row # a callback per pair, in this shell: no subshell, no lost state
report.Sort # sorted in bash, no external sort, no fork
report.count; n=$RESULT
for (( i = n - 1; i >= n - top && i >= 0; i-- )); do
report.Get "$i"; echo "$RESULT" # every member answers in $RESULT
done
On a five-line sample log, with top = 2 in the INI file, it prints:
2 /index.html
2 /api/notes
Every unit follows one contract, so after the first one you can guess the rest: a member returns its value in RESULT rather than on stdout, answers with rc 0 (done), 1 (no / not found) or 2 (a usage error, with a message), and works under set -eu. The reason for RESULT is cost: one x=$(true) is a fork, about 750 µs on Linux and 16 ms on Windows, while a method call is about 0.2 ms. That is why the loop above forks nothing. When you prefer $( ), it still works.
Portability is part of the contract. The test suite has 9,694 tests in 30 suites, and the full run of 2026-10-31 passed all of them. The same suite runs on every push in 15 CI configurations:
Linux, 8 rows: Alpine 3.22 with bash 5.1.16 and 5.2.37, Alpine 3.24 with bash 5.3.20 (musl, busybox tools), Debian 13, Ubuntu 22.04 (bash 5.1.16), Ubuntu 24.04 and Fedora 44 with their system bash, and Ubuntu 26.04 (the GitHub ubuntu-latest runner, bash 5.3.9, with uutils, the Rust rewrite of coreutils, as head, tail, od and ls)
macOS (macos-latest, Homebrew bash), 2 rows: Apple's BSD tools only, and with GNU tools
FreeBSD 15.1, pkg bash, 2 rows: without and with GNU packages
Windows (windows-latest), 3 rows: Git-bash and MSYS2, both on the MSYS2 runtime (a fork of Cygwin), and Cygwin itself. All three report OSTYPE=cygwin, yet MSYS2's grep drops the CR of a CRLF line and Cygwin's keeps it
Locally the suite also runs in VMs (Ubuntu 24.04, Debian 13, Fedora 44, Alpine 3.24 and FreeBSD 15.1, plus Debian under a de_DE locale). In the Debian VM, bash 5.1.16 is built from the GNU sources for the minimum-version run, and kbool is checked to refuse bash 3.2, 4.4 and 5.0, also built from source, with one line.
The core units need no GNU tool and give the same results on every one of them: BSD stat and date, the spelling of \b in each libc, mawk / BWK awk / busybox awk, CRLF input and a decimal comma in EPOCHREALTIME are handled inside the library and pinned by tests. The tool wrappers give GNU behaviour everywhere: they find gsed, gawk, ggrep by name, or refuse with rc 2 and the package to install instead of quietly doing something else. The full list, with the test for each item: https://github.com/Keksov/kbool/blob/main/docs/portability.md
How a script grows is handled by uses, the module system. It plays the role of import in Python, require in Node or Ruby, or include_once in PHP, which plain bash does not have: bash has only source, which takes a path and runs the file again every time. A unit is any file with a two-line header. kk.uses NAME finds it by name, loads it once (a repeated kk.uses costs about 30 µs), and reports a missing unit, a failed load or a dependency cycle with rc 2 and one line. Your own units work the same way as the library's:
# app/lib/weblog.sh: a unit of your own
#!/usr/bin/env bash
[[ ${__KK_UNITS[@]@a} == A* ]] || source "${KBOOL_HOME-}/kbool.sh" || return 2
kk.unit weblog || return $__kk_unit_rc
kk.uses tregex || return # its dependencies, by name
weblog.url() { TRegEx.match "$1" '"[A-Z]+ ([^ ]+)' && RESULT=${RESULT_GROUPS[0]}; }
# app/main.sh
source kbool/kbool.sh || exit
kk.project app.conf || exit # app.conf: "unitpath = lib", relative to the file itself
kk.uses weblog tstringlist || exit
No source "$(dirname "$0")/../lib/…" bookkeeping. The search path is the calling file's folder, then the project's and the user's unitpath, then the library. kk.uses --optional fancy || fallback handles optional dependencies, and kk.defined DEBUG reads defines from the project file. Each unit owns its namespace (weblog.*): a second unit with the same name, or a class that would take the name, is refused at load time, not discovered at run time. Paths in a project file are relative to the file itself, so the project folder can be moved as it is.
All of the library is built on kklass, the class layer, and your own classes can use it too. A class is declared Pascal-style, with the method bodies as plain bash functions (inheritance with inherited, properties, visibility sections, constructors and destructors):
kk.uses kklass_pascal || exit
class TCounter # the interface first, as in a Pascal unit
public
var Name # a field: a plain variable inside the bodies
property Count read GetCount write SetCount # a property with a getter and a setter
constructor Create # runs on TCounter.new
destructor Destroy # runs on .delete
proc Add # a procedure: no return value
func Report # a function: answers in $RESULT, no fork
func GetCount
proc SetCount
static var Instances # one value shared by the whole class
private
var _count # hidden: kklass warns when it is used from outside
end
TCounter.Create() { Name="$1"; _count=0; Instances=$(( Instances + 1 )); }
TCounter.Destroy() { Instances=$(( Instances - 1 )); }
TCounter.Add() { _count=$(( _count + ${1:-1} )); }
TCounter.Report() { RESULT="$Name: $_count"; }
TCounter.GetCount() { RESULT=$_count; }
TCounter.SetCount() { (( $1 >= 0 )) || return 2; _count=$1; } # the setter validates
build TCounter # turns the bodies above into the class
class TLimitedCounter : TCounter # inheritance
public
var Limit
constructor Create
override proc Add # build fails if the parent has no Add: a typo guard
end
TLimitedCounter.Create() { inherited; Limit=${2:-10}; } # the parent's constructor first
TLimitedCounter.Add() {
inherited Add "$@" # the parent's Add, resolved statically, as in Pascal
(( _count <= Limit )) || _count=$Limit
}
build TLimitedCounter
TLimitedCounter.new apples apples 5 # instance name, then the constructor's arguments
apples.Add 3
apples.Add 4 # stops at the limit
apples.Report; echo "$RESULT" # => apples: 5
apples.Count = 2 # goes through SetCount
apples.Count; echo "$RESULT" # => 2
TCounter.Instances # => 1
apples.delete # Destroy runs
TCounter.Instances # => 0
What kbool is good for: scripts that have outgrown functions and globals but must stay bash, such as deploy and build tooling, installers, admin utilities, CI glue and small services. These are scripts where the time goes into logic, structure and error handling, and that have to run on more than one system. What it is not for: a 20-line script, which loading would only slow down (kbool itself loads in 13 to 33 ms, a unit such as tstringlist adds about 150 ms, dateutils about 400 ms); crunching thousands of records, where a pipeline of grep, awk and sort is many times faster (bash reads a line into the shell in about 83 µs, with or without kbool); POSIX sh, and bash older than 5.1. The measurements behind these numbers: https://github.com/Keksov/kbool/blob/main/docs/benchmarks.md
If anyone is interested, I will publish a separate write-up on what kbool gives AI agents that write bash scripts.
Feedback is welcome, especially on the unit set: which class would you reach for first that is not there, and which platform difference has bitten your scripts and is not on the list?