Template-driven CLI applications for free-threaded Python
Project description
ᗣᗣ Milo
Template-driven CLI applications for free-threaded Python
from milo import App, Action
def reducer(state, action):
if state is None:
return {"count": 0}
if action.type == "@@KEY" and action.payload.char == " ":
return {**state, "count": state["count"] + 1}
return state
app = App(template="counter.kida", reducer=reducer, initial_state=None)
final_state = app.run()
What is Milo?
Milo is a framework for building interactive terminal applications in Python 3.14t. It uses the Elm Architecture (Model-View-Update) — an immutable state tree managed by pure reducer functions, a view layer driven by Kida templates, and generator-based sagas for side effects. The result is CLI apps that are predictable, testable, and free-threading ready.
Why people pick it:
- Elm Architecture — Immutable state, pure reducers, declarative views. Every state transition is explicit and testable.
- Template-driven UI — Render terminal output with Kida templates. Same syntax you use for HTML, now for CLI.
- Free-threading ready — Built for Python 3.14t (PEP 703). Sagas run on
ThreadPoolExecutorwith no GIL contention. - Declarative flows — Chain multi-screen state machines with the
>>operator. No manual navigation plumbing. - Built-in forms — Text, select, confirm, and password fields with validation, keyboard navigation, and TTY fallback.
- One runtime dependency — Just
kida-templates. No click, no rich, no curses.
Use Milo For
- Interactive CLI tools — Wizards, installers, configuration prompts, and guided workflows
- Multi-screen terminal apps — Declarative flows with
>>operator for screen-to-screen navigation - Forms and data collection — Text, select, confirm, and password fields with validation
- Dev tools with hot reload —
milo devwatches templates and live-reloads on change - Session recording and replay — Record user sessions to JSONL, replay for debugging or CI regression tests
- Styled terminal output — Kida terminal templates with ANSI colors, progress bars, and live rendering
- AI agent integration — Every CLI is an MCP server; register multiple CLIs behind a single gateway
Installation
pip install milo
Requires Python 3.14+
Quick Start
| Function | Description |
|---|---|
App(template, reducer, initial_state) |
Create a single-screen app |
App.from_flow(flow) |
Create a multi-screen app from a Flow |
app.run() |
Run the event loop, return final state |
Store(reducer, initial_state) |
Standalone state container |
combine_reducers(**reducers) |
Compose slice-based reducers |
form(*specs) |
Run an interactive form, return {field: value} |
FlowScreen(name, template, reducer) |
Define a named screen |
flow = screen_a >> screen_b |
Chain screens into a flow |
render_html(state, template) |
One-shot static HTML render |
DevServer(app, watch_dirs) |
Hot-reload dev server |
Features
| Feature | Description | Docs |
|---|---|---|
| State Management | Redux-style Store with dispatch, listeners, middleware, and saga scheduling |
State → |
| Sagas | Generator-based side effects: Call, Put, Select, Fork, Delay |
Sagas → |
| Flows | Multi-screen state machines with >> operator and custom transitions |
Flows → |
| Forms | Text, select, confirm, password fields with validation and TTY fallback | Forms → |
| Input Handling | Cross-platform key reader with full escape sequence support (arrows, F-keys, modifiers) | Input → |
| Templates | Kida-powered terminal rendering with built-in form, field, help, and progress templates | Templates → |
| Dev Server | milo dev with filesystem polling and @@HOT_RELOAD dispatch |
Dev → |
| Session Recording | JSONL action log with state hashes for debugging and regression testing | Testing → |
| Replay | Time-travel debugging, speed control, step-by-step mode, CI hash assertions | Testing → |
| Snapshot Testing | assert_renders, assert_state, assert_saga for deterministic test coverage |
Testing → |
| Help Rendering | HelpRenderer — drop-in argparse.HelpFormatter using Kida templates |
Help → |
| MCP Server | Every CLI doubles as an MCP server — AI agents discover and call commands via JSON-RPC | MCP → |
| MCP Gateway | Single gateway aggregates all registered Milo CLIs for unified AI agent access | MCP → |
| llms.txt | Generate AI-readable discovery documents from CLI command definitions | llms.txt → |
| Error System | Structured error hierarchy with namespaced codes (M-INP-001, M-STA-003) |
Errors → |
Usage
Single-Screen App — Counter with keyboard input
from milo import App, Action
def reducer(state, action):
if state is None:
return {"count": 0}
if action.type == "@@KEY" and action.payload.char == " ":
return {**state, "count": state["count"] + 1}
return state
app = App(template="counter.kida", reducer=reducer, initial_state=None)
final_state = app.run()
counter.kida:
Count: {{ count }}
Press SPACE to increment, Ctrl+C to quit.
Multi-Screen Flow — Chain screens with >>
from milo import App
from milo.flow import FlowScreen
welcome = FlowScreen("welcome", "welcome.kida", welcome_reducer)
config = FlowScreen("config", "config.kida", config_reducer)
confirm = FlowScreen("confirm", "confirm.kida", confirm_reducer)
flow = welcome >> config >> confirm
app = App.from_flow(flow)
app.run()
Navigate between screens by dispatching @@NAVIGATE actions from your reducers. Add custom transitions with flow.with_transition("welcome", "confirm", on="@@SKIP").
Interactive Forms — Collect structured input
from milo import form, FieldSpec, FieldType
result = form(
FieldSpec("name", "Your name"),
FieldSpec("env", "Environment", field_type=FieldType.SELECT,
choices=("dev", "staging", "prod")),
FieldSpec("confirm", "Deploy?", field_type=FieldType.CONFIRM),
)
# result = {"name": "Alice", "env": "prod", "confirm": True}
Tab/Shift+Tab navigates fields. Arrow keys cycle select options. Falls back to plain input() prompts when stdin is not a TTY.
Sagas — Generator-based side effects
from milo import Call, Put, Select, ReducerResult
def fetch_saga():
url = yield Select(lambda s: s["url"])
data = yield Call(fetch_json, (url,))
yield Put(Action("FETCH_DONE", payload=data))
def reducer(state, action):
if action.type == "@@KEY" and action.payload.char == "f":
return ReducerResult({**state, "loading": True}, sagas=(fetch_saga,))
if action.type == "FETCH_DONE":
return {**state, "loading": False, "data": action.payload}
return state
Effects: Call(fn, args), Put(action), Select(selector), Fork(saga), Delay(seconds).
Middleware — Intercept and transform dispatches
def logging_middleware(dispatch, get_state):
def wrapper(action):
print(f"Action: {action.type}")
return dispatch(action)
return wrapper
app = App(
template="app.kida",
reducer=reducer,
initial_state=None,
middleware=[logging_middleware],
)
Dev Server — Hot reload templates
# Watch templates and reload on change
milo dev myapp:app --watch ./templates --poll 0.25
from milo import App, DevServer
app = App(template="dashboard.kida", reducer=reducer, initial_state=None)
server = DevServer(app, watch_dirs=("./templates",), poll_interval=0.5)
server.run()
Session Recording & Replay — Debug and regression testing
# Record a session
app = App(template="app.kida", reducer=reducer, initial_state=None, record=True)
app.run() # Writes to session.jsonl
# Replay for debugging
milo replay session.jsonl --speed 2.0 --diff
# CI regression: assert state hashes match
milo replay session.jsonl --assert --reducer myapp:reducer
# Step-by-step interactive replay
milo replay session.jsonl --step
Testing Utilities — Snapshot, state, and saga assertions
from milo.testing import assert_renders, assert_state, assert_saga
from milo import Action, Call
# Snapshot test: render state through template, compare to file
assert_renders({"count": 5}, "counter.kida", snapshot="tests/snapshots/count_5.txt")
# Reducer test: feed actions, assert final state
assert_state(reducer, None, [Action("@@INIT"), Action("INCREMENT")], {"count": 1})
# Saga test: step through generator, assert each yielded effect
assert_saga(my_saga(), [(Call(fetch, ("url",), {}), {"data": 42})])
Set MILO_UPDATE_SNAPSHOTS=1 to regenerate snapshot files.
MCP Server & Gateway — AI agent integration
Every Milo CLI is automatically an MCP server:
# Run as MCP server (stdin/stdout JSON-RPC)
myapp --mcp
# Register with an AI host directly
claude mcp add myapp -- uv run python examples/taskman/app.py --mcp
For multiple CLIs, register them and run a single gateway:
# Register CLIs
taskman --mcp-install
ghub --mcp-install
# Run the unified gateway
uv run python -m milo.gateway --mcp
# Or register the gateway with your AI host
claude mcp add milo -- uv run python -m milo.gateway --mcp
The gateway namespaces tools automatically: taskman.add, ghub.repo.list, etc. Implements MCP 2025-11-25 with outputSchema, structuredContent, and tool title fields.
Architecture
Elm Architecture — Model-View-Update loop
┌──────────────┐
│ Terminal │
│ (View) │
└──────┬───────┘
│ Key events
▼
┌──────────┐ ┌──────────────────┐ ┌──────────────┐
│ Kida │◄───│ Store │◄───│ Reducer │
│ Template │ │ (State Tree) │ │ (Pure fn) │
└──────────┘ └──────────┬───────┘ └──────────────┘
│
▼
┌──────────────┐
│ Sagas │
│ (Side Effects│
│ on ThreadPool)
└──────────────┘
- Model — Immutable state (plain dicts or frozen dataclasses)
- View — Kida templates render state to terminal output
- Update — Pure
reducer(state, action) -> statefunctions - Effects — Generator-based sagas scheduled on
ThreadPoolExecutor
Event Loop — App lifecycle
App.run()
├── Store(reducer, initial_state)
├── KeyReader (raw mode, escape sequences → Key objects)
├── TerminalRenderer (alternate screen buffer, flicker-free updates)
├── Optional: tick thread (@@TICK at interval)
├── Optional: SIGWINCH handler (@@RESIZE)
└── Loop:
read key → dispatch @@KEY → reducer → re-render
until state.submitted or @@QUIT
Builtin Actions — Event vocabulary
| Action | Trigger | Payload |
|---|---|---|
@@INIT |
Store creation | — |
@@KEY |
Keyboard input | Key(char, name, ctrl, alt, shift) |
@@TICK |
Timer interval | — |
@@RESIZE |
Terminal resize | (cols, rows) |
@@NAVIGATE |
Screen transition | screen_name |
@@HOT_RELOAD |
Template file change | file_path |
@@EFFECT_RESULT |
Saga completion | result |
@@QUIT |
Ctrl+C | — |
Documentation
| Section | Description |
|---|---|
| Get Started | Installation and quickstart |
| Usage | State, sagas, flows, forms, templates |
| Testing | Snapshots, recording, replay |
| MCP & AI | MCP server, gateway, and llms.txt |
| Reference | Complete API documentation |
Development
git clone https://github.com/lbliii/milo.git
cd milo
# Uses Python 3.14t by default (.python-version)
uv sync --group dev --python 3.14t
PYTHON_GIL=0 uv run --python 3.14t pytest
The Bengal Ecosystem
A structured reactive stack — every layer written in pure Python for 3.14t free-threading.
| ᓚᘏᗢ | Bengal | Static site generator | Docs |
| ∿∿ | Purr | Content runtime | — |
| ⌁⌁ | Chirp | Web framework | Docs |
| =^..^= | Pounce | ASGI server | Docs |
| )彡 | Kida | Template engine | Docs |
| ฅᨐฅ | Patitas | Markdown parser | Docs |
| ⌾⌾⌾ | Rosettes | Syntax highlighter | Docs |
| ᗣᗣ | Milo | CLI framework ← You are here | Docs |
Python-native. Free-threading ready. No npm required.
License
MIT License — see LICENSE for details.
Project details
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file milo_cli-0.1.0.tar.gz.
File metadata
- Download URL: milo_cli-0.1.0.tar.gz
- Upload date:
- Size: 107.8 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6457edcfc32da973714182630cedf6e5ed40af8a31c37a9fb7c5c3498d4b0145
|
|
| MD5 |
bb1eb63c3aae5e4dcf634374f1c27b70
|
|
| BLAKE2b-256 |
c92b7c611080a0be29ed38bd18bed3c62722dad8193bd2dc0f5e17082c2c8050
|
Provenance
The following attestation bundles were made for milo_cli-0.1.0.tar.gz:
Publisher:
python-publish.yml on lbliii/milo-cli
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
milo_cli-0.1.0.tar.gz -
Subject digest:
6457edcfc32da973714182630cedf6e5ed40af8a31c37a9fb7c5c3498d4b0145 - Sigstore transparency entry: 1201520297
- Sigstore integration time:
-
Permalink:
lbliii/milo-cli@b014b003941e0fe48d55e5448b78d10fa0e9caca -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/lbliii
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@b014b003941e0fe48d55e5448b78d10fa0e9caca -
Trigger Event:
release
-
Statement type:
File details
Details for the file milo_cli-0.1.0-py3-none-any.whl.
File metadata
- Download URL: milo_cli-0.1.0-py3-none-any.whl
- Upload date:
- Size: 73.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.7
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
2a66ebeb5ec6e361f9d662a9b09377199eadd6669a191653f3e73e7e8d2352b2
|
|
| MD5 |
19bda970215c5925fe56fbe8c54c3615
|
|
| BLAKE2b-256 |
97ae4522cc13b0ab165d819a1f9106346ca2c30b50c098d79d79a1f400734b0f
|
Provenance
The following attestation bundles were made for milo_cli-0.1.0-py3-none-any.whl:
Publisher:
python-publish.yml on lbliii/milo-cli
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
milo_cli-0.1.0-py3-none-any.whl -
Subject digest:
2a66ebeb5ec6e361f9d662a9b09377199eadd6669a191653f3e73e7e8d2352b2 - Sigstore transparency entry: 1201520300
- Sigstore integration time:
-
Permalink:
lbliii/milo-cli@b014b003941e0fe48d55e5448b78d10fa0e9caca -
Branch / Tag:
refs/tags/v0.1.0 - Owner: https://github.com/lbliii
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@b014b003941e0fe48d55e5448b78d10fa0e9caca -
Trigger Event:
release
-
Statement type: