All projects
v0.2.1 · in progressProject 10
Extensible CLIGit pluginsDevTools

EagleKit

Project CLI with a plugin system built on Python entry points. The hard part was not the commands: it was coordinating two Git hooks that run as separate processes, share no memory, and have a human who can abort between them.

Python 3.9+TyperRichimportlib.metadataGit hooksSQLite / JSON

Putting [7][pagos-qa] in front of a commit looks like one line of bash. It is two processes that never overlap in time.

EagleKit is a project CLI (ek) that grows through pip-installable plugins. The Git plugins number commits per branch and mirror QA → Prod with cherry-pick. The hard part was not the commands: it was that Git spawns each hook as a new process, with no shared memory, and a human can abort between the two.

process 1 · prepare-commit-msg
Bumps the counter, generates the UUID, writes the prefix. Then dies.
in between · you
You save the message or close the editor. Nobody tells process 1.
process 2 · post-commit
The only one that knows the hash. It needs the UUID from process 1.
4runtime dependencies in the core: Typer, Rich, PyYAML, platformdirs
38subcommands in the core; 19 more across the two public plugins
2Git hooks per repository: prepare-commit-msg and post-commit
01 · How it works

One command that does not know which subcommands it will have tomorrow

Click through the steps or let them play. The core imports no plugin: it discovers them at startup.

Your shell
ek() function
ek-core
Typer + Rich app
entry points
importlib.metadata
Plugin
register(app)
State
YAML · JSON · SQLite
Step 1 of 5 · plays on its own
You type ek mirror push
The ek() function installed in your .zshrc checks whether the command is ek cd. If it is not, it hands it over untouched to ek-core, the Python process. A subprocess cannot change your shell's directory; that is why cd stays out of Python.

A whole plugin is one function and one line in pyproject.toml.

# eaglekit_plugin_mirror/__init__.py
def register(app):
app.add_typer(mirror_app, name="mirror")
# pyproject.toml
[project.entry-points."eaglekit.plugins"]
mirror = "eaglekit_plugin_mirror:register"
$ pipx inject eaglekit eaglekit-plugin-mirror
$ ek mirror --help
02 · Architecture

A shell wrapper, one Python process and three places where state lives

from keystroke to file
Shell
ek()
function in .zshrc / .bashrc
intercepts ek cd
ek
Python entry point
wrapper
everything else is delegated to ek-core
CLI
ek-core
Typer app · Rich output · 38 subcommands
v0.2.1
entry points · eaglekit.plugins
Plugins
tracker
[n][branch] + UUID per commit
11 commands
mirror
QA ↔ Prod with cherry-pick
8 commands
flow
tracker + mirror unified
SQLite
each one writes to its own repo's .git/
State
~/.config/
eaglekit/registry.yaml · defaults.yaml
.eagle/
config.yaml · todos.yaml · scripts/
.git/eaglekit_*
branches/*.json · links.json · flow.db

What the core does with no plugin installed.

Projects
ek add ., ek list, ek status. Workspaces to keep work, personal and client projects apart.
Navigation
ek add generates ~/.eagle_projects with one variable per project: cd $api. Or ek cd api through the shell function.
Tasks per branch
YAML in .eagle/config.yaml; .eagle/branches/<branch>/ overrides by name. String, list or script.
TODOs and notes
Per project, in YAML inside .eagle/. 15 subcommands between todo and comment.
Ignoring .eagle/
Four strategies: local (.git/info/exclude), repo, global or none. The recommended one touches nobody else's repo.
Plugins
ek plugins lists loaded and failed ones with their error. First-run wizard with ek setup.
03 · The hooks

What happens inside git commit when the tracker is installed

Each hook is a new process that Git spawns and discards. This is the real flow of the public plugin, step by step.

git commit
your terminal
prepare-commit-msg
process 1
Your editor
save or abort
post-commit
process 2
.git/…
branches/<branch>.json
Step 1 of 4 · plays on its own
Git spawns the first hook, and it dies on exit
prepare-commit-msg is a bash script with embedded Python. It reads the branch counter, bumps it from 6 to 7, generates a UUID and prepends [7][pagos-qa] to the message. When it finishes, its memory is gone.
$ ek tracker init
✓ hooks installed: prepare-commit-msg, post-commit
$ git commit -m "Validate the payment amount"
[7][pagos-qa] Validate the payment amount
# amend, merge, squash and cherry-pick are not numbered: the hook detects them and exits
$ SKIP_EAGLEKIT_HOOK=1 git commit -m "hotfix without prefix"
04 · An end-to-end example

Ana runs payments on two branches

A made-up case with three commits, marked as an example. The commands and the files are the real ones.

1
Monday · API repo
Create the branch pair
ek mirror create pagos creates pagos-qa and pagos-prod and links them in .git/eaglekit_mirror/links.json. ek tracker init installs the two hooks.
2
Monday → Wednesday
Three commits on QA
Each one gets its prefix: [1][pagos-qa], [2][pagos-qa], [3][pagos-qa]. The second hook stores index, hash and UUID in branches/pagos-qa.json.
3
Thursday
Push QA to Prod
ek mirror push lists the QA commits whose UUID is not in pushed_logical_ids and applies them with git cherry-pick, one at a time. Before each one it leaves a metadata file so the hook preserves the UUID.
ek mirror push · the hash changes, the logical_id does not
pagos-qa
[1]a1f3c02
[2]7c0e9d1
[3]e9b2447
git cherry-pick, one at a time · the UUID travels in a metadata file
pagos-prod
[1]5d21b8e
[2]c47a610
[3]conflict
The third one conflicts. The state stores pending_commits and exits. You resolve it in the editor and continue with ek mirror push --continue: the UUID filter does not apply 1 or 2 again.
4
Thursday · a minute later
The third one conflicts
The operation stops. sync_state.json stores the pending ones and marks in_progress. Ana resolves the conflict, runs git cherry-pick --continue, and ek mirror push --continue finishes what was left.
5
Friday
Check and move on
ek mirror status: 0 pending in each direction. A hotfix made directly on Prod goes back to QA with ek mirror pull through the same mechanism.
05 · The challenges

Five times the happy path was not the problem

Same pattern every time: what anyone would try first, why it does not work, what was done. The technical detail stays folded.

01

Two hooks, one number and a human in between

The obvious approach
Have prepare-commit-msg read the counter, bump it and write the prefix. Done.
Why it fails
The counter is incremented before the commit exists. If you close the editor without saving, there is a gap: 7 was spent and nobody used it. And the hash, which is what has to be stored, is only known by post-commit, another process that starts once the first one is already dead. No environment variable survives that jump.
What was done
In the public plugins, the first hook leaves the UUID in .git/eaglekit_pending_uuid and the second one consumes and deletes it. In Flow, the unified version, the counter lives in SQLite with INSERT … ON CONFLICT DO UPDATE SET next_seq = next_seq + 1, and the design tolerates gaps: commits are identified by logical_id, not by a gapless sequence.
1 filesingle-use, is the whole channel between the two processes. If the commit is aborted, the next prepare-commit-msg overwrites it.
Technical detail

The hooks are installed as bash with Python embedded in a heredoc, without importing the package: that way they work even when the CLI's virtual environment is not active in the terminal where you commit. Values are passed to Python through environment variables to avoid quoting problems with multi-line messages.

The hook skips merges, squashes, cherry-picks and --amend by reading the second argument Git passes it, and honors SKIP_EAGLEKIT_HOOK=1. Documented in the tracker's ARCHITECTURE.md.

02

Cherry-pick changes the hash, not the identity

The obvious approach
Remember which hashes were already synced and never apply them again.
Why it fails
A cherry-pick creates a new commit, with a different hash. The same change has one hash on pagos-qa and another on pagos-prod. Comparing by hash applies it twice. And the hook that records the commit on Prod skipped itself because it detected a cherry-pick: nobody tells it which UUID the original had.
on pagos-qa
after cherry-pick to pagos-prod
hash
e9b2447
91be0c3 · changes
prefix
[3][pagos-qa]
[3][pagos-qa] · lives in the message
logical_id
550e8400-…
550e8400-… · preserved
The sync compares UUIDs, never hashes. A commit that is already synced is not applied again even though its hash differs on each branch.
What was done
Each commit carries a logical_id (UUID v4) generated exactly once. The mirror plugin writes that UUID to a metadata file inside .git/ before each cherry-pick; post-commit gives it priority over the pending UUID and deletes it. The sync filter compares UUIDs and falls back to hashes only when there is no UUID.
2 → 1plugins. Coordinating tracker and mirror through files with an agreed name turned out fragile; they were unified into Flow with a single database.
Technical detail

Order matters: metadata, cherry-pick, read the new HEAD, store state. If the cherry-pick conflicts, the metadata is cleaned up before exiting so the next normal commit does not inherit someone else's UUID.

In the public versions, mirror writes .git/eaglekit_hooks_metadata.json and the tracker hook reads .git/eaglekit_commit_tracker_metadata.json. Two names for the same contract: the mirrored commit ends up without a row in the tracker's JSON. It is exactly the kind of failure that motivated unifying them.
03

Amend and rebase leave ghost hashes

The obvious approach
Store the hash on the commit and trust it will not change.
Why it fails
git commit --amend and git rebase rewrite hashes. The database keeps pointing at commits that no longer exist, and a push would try to cherry-pick ghosts. None of this shows up on the happy path.
What was done
In the public tracker, the hook does not number amends and explicit renumbering is a separate command: ek tracker rewrite --dry-run, then --force, with a JSON copy of the mappings before touching anything. Flow added the third hook, post-rewrite: Git passes it the old → new pairs on stdin and each pair is an UPDATE inside one transaction.
3 hooksin Flow: commit-msg, post-commit and post-rewrite. The third one almost got left out.
Technical detail

The non-destructive alternative is ek tracker reindex: it fixes the internal numbering without changing history. rewrite only when the commit message has to show the right number, followed by git push --force-with-lease.

04

A broken plugin cannot take down the CLI

The obvious approach
Iterate the entry points, call ep.load() and invoke whatever comes back.
Why it fails
Three different things fail there. The entry_points() API returns a dict on Python 3.9 and an object with .select() on 3.10+. A plugin with an import error takes down all of ek, including ek list, which does not need it. And nothing guarantees the entry point is callable: the real error seen in use was 'module' object is not callable.
$ ek plugins
Plugin Module Status Error
mirror eaglekit_plugin_mirror:register ✓ Loaded
tracker eaglekit_plugin_commit_tracker:register ✓ Loaded
demo eaglekit_plugin_demo ✗ Failed entry point is not callable
What was done
hasattr(eps, 'select') at runtime to support both APIs. A try/except per plugin that separates validation failure from load failure and stores both with their message. ek plugins shows loaded and failed ones in the same table.
stderrfor every loader message. They used to go to stdout and broke ek list | grep api.
05

Silent name collision in Typer

The obvious approach
Let each plugin register its group and let Typer handle the rest.
Why it fails
Typer does not detect two plugins calling add_typer with the same name. The second one overwrites the first with no error and no warning. It came up while testing tracker and mirror as separate plugins: had both picked sync, one would have vanished.
plugin A
app.add_typer(sub, name="sync")
plugin B
app.add_typer(sub, name="sync")
→
ek sync
The last one loaded wins. No error, no warning: the first one disappears.
What was done
They were unified into a single plugin, Flow, with a single namespace. The eaglekit-plugin-<name> naming convention reduces the risk but does not remove it.
0 warningstoday. The loader still does not detect collisions: it sits in the backlog as a documented case. It does not affect the standard install; it does affect third-party plugins.
"80% of the debugging time went to edge cases."
Designing the happy path took a day. Making sure nothing breaks when someone aborts a commit halfway, amends, or the fifth of five cherry-picks conflicts, took weeks. The complexity of a CLI is not in the commands: it is in the resilience.
06 · Decisions

Where each thing goes, and why

Almost all of them are the same question: does someone who only wants ek add . and ek list need this?

WHY
Why two entry points, ek and ek-core, plus a shell function?
Because a subprocess cannot cd in the shell that launched it. The ek() function intercepts ek cd api, asks for the path with ek-core cd --path api and runs the real cd. Everything else passes straight through to Python. It is the same pattern nvm, pyenv and direnv use.
Go deeper

ek shell install adds the function between markers in .zshrc or .bashrc; uninstall removes only that block. ek add also regenerates ~/.eagle_projects with export api="/path" per project, so cd $api works without the function.

There is a router.py that translates ek deploy into ek-core run task deploy, and the README documents it, but in v0.2.1 the ek entry point points at the wrapper, not the router. Today the shortcut is not wired: the real form is ek run task deploy.
WHY
Why Python entry points instead of a plugins folder?
Because installing is registering. pipx inject eaglekit eaglekit-plugin-mirror and the command exists; the plugin's dependencies are resolved by packaging, not by the CLI. A ~/.eaglekit/plugins/ folder would mean inventing a format, versioning it and resolving imports by hand.
Go deeper

The contract is deliberately minimal: one group, eaglekit.plugins, and one function, register(app). The core knows no plugin by name and there is no central list to maintain. The cost is challenge 05: each plugin picks its own namespace.

WHY
Why is Git tracking a plugin and not part of the core?
The core is a project manager. Someone who only registers paths and runs tasks should not load hooks, cherry-picks or a database they will never use. The split also enforced a discipline: the plugin imports nothing from the core; all communication goes through file system conventions.
Go deeper

Each plugin can be installed on its own: it declares eaglekit as a dependency and pulls in the core if it is missing. The two public plugins follow Clean Architecture with domain, application, infrastructure and presentation layers: the mirror's ARCHITECTURE.md.

WHY
JSON in the public plugins, SQLite in Flow: a real migration
With JSON, each hook does read-modify-write with no locking, a cross query opens two files and an atomic push touches three. Flow replaces them with one flow.db in .git/eaglekit/: WAL mode for writes from hooks, indexed SQL for queries and one transaction per cherry-pick. SQLite ships with Python: no server, no new dependency.
Go deeper
Flow · SQLite
tracker + mirror · JSON
State files
1: flow.db
branches/*.json, links.json, sync_state.json and the inherited .map files
Writes from hooks
WAL + transaction
read-modify-write with no locking
Cross query
one indexed SELECT
open and join two files
Cherry-pick handshake
row in the same transaction
metadata file with an agreed name
Flow is the unification of the two public plugins and the one the core README documents (ek flow init · create · push · pull · rebuild). Its repository is not published yet.
07 · What I would do differently

Five decisions I would take the other way today

I did
A 2,571-line cli.py holding the whole core
Today I would
Modules by responsibility: commands/projects.py, tasks.py, todos.py. Today changing how TODOs are persisted is a search through 2,500 lines.
I did
The registry as an untyped Dict[str, Any]
Today I would
A Registry dataclass with entities, as the plugins already have (CommitEntry, MirrorLink, SyncState). The core deserves the same modeling as its plugins.
I did
Two plugins talking through files with an agreed name
Today I would
A shared module that owns the handshake's name and format. A string duplicated across two repos drifts; an imported constant does not.
I did
CREATE TABLE IF NOT EXISTS with no migrations
Today I would
A schema_version table and sequential migrations. Without that, an existing flow.db never gets new columns.
I did
Hooks that can only be tested against a real Git repo
Today I would
Have the hook logic receive the Git service and the connection as parameters. Today the bash with embedded Python cannot be unit tested.
08 · Three things to take away

Recap

1
A single ek command that does not know its plugins: it discovers them through entry points at startup, each in its own namespace, and a broken plugin only produces a warning.
2
Two Git hooks are two processes with no shared memory and a human between them. They coordinate through the file system, and the design tolerates the human aborting.
3
Cherry-pick changes the hash, so a commit's identity is a UUID that travels with it. That is what makes mirroring QA → Prod possible without applying anything twice.
Links