For a long time keel had a sentence at the top of its README that quietly limited it:
an enforced workflow for a Kotlin + Spring Boot backend with a TypeScript frontend
The workflow itself was never about Kotlin. Nothing in "write the failing test first" mentions Gradle. But the two stacks it shipped with were baked into the code, and adding a third meant editing keel rather than configuring it.
That's fixed. The stack is now data, and a new language is a file you drop in.
Installing one
keel packs add packs # this machine
keel packs add packs --project # or just this project
Two things in that output are deliberate.
It prints the commands the pack brings. A pack's commands: get merged into your project's
effective config — installing one installs things keel will actually run. That belongs in front
of you at the moment you install it, not discovered three days later when a check fails.
It tells you where each pack came from. Two are built in, two are this project. That
matters, because packs come from three places and the most specific one wins:
.keel/stacks/ this project ─┐
~/.keel/stacks/ this machine ─┼── first one holding a pack of that name wins
<keel>/stacks/ built in ─┘
So a project can pin or override a stack without touching anything machine-wide.
What a pack actually is
One YAML file. It says which lane it belongs to, how to recognise itself, what its test layers are, what commands to run, and which skills teach it:
name: symfony
lane: api
detect:
files: [composer.json, symfony.lock, bin/console]
extensions: ['.php']
commands:
api_test_module: 'vendor/bin/phpunit'
static_checks: 'vendor/bin/phpstan analyse'
migrate: 'bin/console doctrine:migrations:migrate --no-interaction'
Nothing in keel's code names symfony. It probes every pack's detect: block against your
directories and picks the one that matches. Which means the honest test of "is this really
pluggable" is whether a new pack works with no code change at all — and that is now the case.
Three answers, not one
Detection has three outcomes and keel is explicit about all of them:
- One match — that pack. The ordinary case.
- No match — falls back to the built-in for that lane, and says so rather than pretending it detected something.
- More than one — a real state when a project is mid-migration between two stacks. keel refuses to guess and names the config key that settles it.
That last one is the interesting case. A repo moving from Kotlin to Symfony genuinely has both for a while. Silently picking one would be worse than asking.
The part I'd have got wrong
An installed pack is not written by the person who wrote keel. That changes the threat model.
keel's YAML reader never throws — it skips lines it can't read. So a truncated or half-cloned
pack parses to an empty object. And a pack with no detect: block matches everything. Put
those together and one broken install would claim every project on your machine and contribute
its commands to all of them.
So a pack keel didn't ship is shape-checked before it's trusted: it must declare a name, a lane, and at least one detection signal, or it's skipped by name. Built-ins skip the check — they ship with keel and are covered by its own test suite.
Where they live
The optional packs ship inside the keel repo under packs/, but they're inert — nothing
reads them until keel packs add installs them. That keeps keel's default surface small: the two
built-ins work with zero installs, and a stack you don't use costs you nothing.
An installed pack brings everything it needs with it: its own testing and implementation skills,
its own architecture placement references, and its own starter for keel init --new. keel
carries nothing for a language it doesn't ship.
keel is a Claude Code plugin. github.com/MiladNalbandi/keel

