Template-driven CLI applications for free-threaded Python
Project description
ᗣᗣ Milo
Build CLIs that humans and AI agents both use natively
from milo import CLI
cli = CLI(name="deployer", description="Deploy services to environments")
@cli.command("deploy", description="Deploy a service", annotations={"destructiveHint": True})
def deploy(environment: str, service: str, version: str = "latest") -> dict:
"""Deploy a service to the specified environment."""
return {"status": "deployed", "environment": environment, "service": service, "version": version}
cli.run()
Three protocols from one decorator:
# Human CLI
deployer deploy --environment production --service api
# MCP tool (AI agent calls this via JSON-RPC)
echo '{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"deploy","arguments":{"environment":"staging","service":"api"}}}' \
| deployer --mcp
# AI-readable discovery document
deployer --llms-txt
What is Milo?
Milo is a Python framework where every CLI is simultaneously a terminal app, a command-line tool, and an MCP server. Write one function with type annotations and a docstring — Milo generates the argparse subcommand, the MCP tool schema, and the llms.txt entry automatically.
Why people pick it:
- Every CLI is an MCP server —
@cli.commandproduces an argparse subcommand, MCP tool, and llms.txt entry from one function. AI agents discover and call your tools with zero extra code. - Dual-mode commands — The same command shows an interactive UI when a human runs it, and returns structured JSON when an AI calls it via MCP.
- Annotated schemas — Type hints +
Annotatedconstraints generate rich JSON Schema. Agents validate inputs before calling. - Streaming progress — Commands that yield
Progressobjects stream notifications to MCP clients in real time. - Elm Architecture — Immutable state, pure reducers, declarative views. Every state transition is explicit and testable.
- Free-threading ready — Built for Python 3.14t (PEP 703). Sagas run on
ThreadPoolExecutorwith no GIL contention. - One runtime dependency — Just
kida-templates. No click, no rich, no curses.
Use Milo For
- AI agent toolchains — Every CLI doubles as an MCP server; register multiple CLIs behind a single gateway
- Interactive CLI tools — Wizards, installers, configuration prompts, and guided workflows
- Dual-mode commands — Interactive when a human runs them, structured when an AI calls them
- 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
Installation
pip install milo-cli
The PyPI package is milo-cli; import the milo namespace in Python. The milo console command is installed with the package.
Requires Python 3.14+
Quick Start
AI-Native CLI
| Function | Description |
|---|---|
CLI(name, description, version) |
Create a CLI application |
@cli.command(name, description) |
Register a typed command |
cli.group(name, description) |
Create a command group |
cli.run() |
Parse args and dispatch |
cli.call("cmd", **kwargs) |
Programmatic invocation |
--mcp |
Run as MCP server |
--llms-txt |
Generate AI discovery doc |
--mcp-install |
Register in gateway |
annotations={...} |
MCP behavioral hints |
Annotated[str, MinLen(1)] |
Schema constraints |
Interactive Apps
| Function | Description |
|---|---|
App(template, reducer, initial_state) |
Create a single-screen app |
App.from_flow(flow) |
Create a multi-screen app from a Flow |
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 |
ctx.run_app(reducer, template, state) |
Bridge CLI commands to interactive apps |
quit_on, with_cursor, with_confirm |
Reducer combinator decorators |
Cmd(fn), Batch(cmds), Sequence(cmds) |
Side effects on thread pool |
ViewState(cursor_visible=True, ...) |
Declarative terminal state |
DevServer(app, watch_dirs) |
Hot-reload dev server |
Features
| Feature | Description | Docs |
|---|---|---|
| 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 → |
| Tool Annotations | Declare readOnlyHint, destructiveHint, idempotentHint per MCP spec |
MCP → |
| Streaming Progress | Commands yield Progress objects; MCP clients receive real-time notifications |
MCP → |
| Schema Constraints | Annotated[str, MinLen(1), MaxLen(100)] generates rich JSON Schema |
CLI → |
| llms.txt | Generate AI-readable discovery documents from CLI command definitions | llms.txt → |
| Middleware | Intercept MCP calls and CLI commands for logging, auth, and transformation | CLI → |
| Observability | Built-in request logging with latency stats (milo://stats resource) |
MCP → |
| State Management | Redux-style Store with dispatch, listeners, middleware, and saga scheduling |
State → |
| Commands | Lightweight Cmd thunks, Batch, Sequence, TickCmd for one-shot effects |
Commands → |
| Sagas | Generator-based side effects: Call, Put, Select, Fork, Delay, Retry |
Sagas → |
| ViewState | Declarative terminal state (cursor_visible, alt_screen, window_title, mouse_mode) |
Commands → |
| 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 → |
| Snapshot Testing | assert_renders, assert_state, assert_saga for deterministic test coverage |
Testing → |
| Help Rendering | HelpRenderer — drop-in argparse.HelpFormatter using Kida templates |
Help → |
| Context | Execution context with verbosity, output format, global options, and run_app() bridge |
Context → |
| Configuration | Config with validation, init scaffolding, and profile support |
Config → |
| Shell Completions | Generate bash/zsh/fish completions from CLI definitions | CLI → |
| Doctor Diagnostics | run_doctor() validates environment, dependencies, and config health |
CLI → |
Usage
Dual-Mode Commands — Interactive for humans, structured for AI
from milo import CLI, Context, Action, Quit, SpecialKey
from milo.streaming import Progress
from typing import Annotated
from milo import MinLen
cli = CLI(name="deployer", description="Deploy services")
@cli.command("deploy", description="Deploy a service", annotations={"destructiveHint": True})
def deploy(
environment: Annotated[str, MinLen(1)],
service: Annotated[str, MinLen(1)],
ctx: Context = None,
) -> dict:
"""Deploy a service to an environment."""
# Interactive mode: show confirmation UI
if ctx and ctx.is_interactive:
if not ctx.confirm(f"Deploy {service} to {environment}?"):
return {"status": "cancelled"}
# Stream progress (MCP clients see real-time notifications)
yield Progress(status=f"Deploying {service}", step=0, total=2)
yield Progress(status="Verifying health", step=1, total=2)
return {"status": "deployed", "environment": environment, "service": service}
Run by a human: interactive confirmation, then progress output. Called via MCP: progress notifications stream, then structured JSON result.
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/deploy/app.py --mcp
For multiple CLIs, register them and run a single gateway:
# Register CLIs
taskman --mcp-install
deployer --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, deployer.deploy, etc. Implements MCP 2025-11-25 with outputSchema, structuredContent, tool annotations, and streaming Progress notifications.
Built-in milo://stats resource exposes request latency, error counts, and throughput.
Schema Constraints — Rich validation from type hints
from typing import Annotated
from milo import CLI, MinLen, MaxLen, Gt, Lt, Pattern, Description
cli = CLI(name="app")
@cli.command("create-user", description="Create a user account")
def create_user(
name: Annotated[str, MinLen(1), MaxLen(100), Description("Full name")],
age: Annotated[int, Gt(0), Lt(200)],
email: Annotated[str, Pattern(r"^[^@]+@[^@]+$")],
) -> dict:
return {"name": name, "age": age, "email": email}
Generates JSON Schema with minLength, maxLength, exclusiveMinimum, exclusiveMaximum, pattern, and description — AI agents validate inputs before calling.
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
Saga effects: Call(fn, args), Put(action), Select(selector), Fork(saga), Delay(seconds), Retry(fn, ...).
For one-shot effects, use Cmd instead — no generator needed:
from milo import Cmd, ReducerResult
def fetch_status():
return Action("STATUS", payload=urllib.request.urlopen(url).status)
def reducer(state, action):
if action.type == "CHECK":
return ReducerResult(state, cmds=(Cmd(fetch_status),))
return state
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.
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 —
Cmdthunks (one-shot) or generator-based sagas (multi-step) onThreadPoolExecutor
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 |
| MCP & AI | MCP server, gateway, annotations, streaming, and llms.txt |
| Usage | State, sagas, flows, forms, templates |
| Testing | Snapshots, recording, replay |
| Reference | Complete API documentation |
Development
git clone https://github.com/lbliii/milo-cli.git
cd milo-cli
# Uses Python 3.14t by default (.python-version)
uv sync --group dev --python 3.14t
PYTHON_GIL=0 uv run --python 3.14t pytest tests/
make ci # optional: ruff + ty + tests with coverage
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 (PyPI: milo-cli) |
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.1.tar.gz.
File metadata
- Download URL: milo_cli-0.1.1.tar.gz
- Upload date:
- Size: 151.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 |
3851c3dceeae17577a91e24d7338ebbeffb1edcd331a7347386e775567c42e87
|
|
| MD5 |
12f50774b8942c1db78f2b6f1707c009
|
|
| BLAKE2b-256 |
5c33bb2f33989487fe2e89f4ce1e06faa401cf20a0884d7d17329ca0b902eb5a
|
Provenance
The following attestation bundles were made for milo_cli-0.1.1.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.1.tar.gz -
Subject digest:
3851c3dceeae17577a91e24d7338ebbeffb1edcd331a7347386e775567c42e87 - Sigstore transparency entry: 1234808441
- Sigstore integration time:
-
Permalink:
lbliii/milo-cli@bb33a1b2194df4950e600f8b45fd521f22e70ff2 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/lbliii
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@bb33a1b2194df4950e600f8b45fd521f22e70ff2 -
Trigger Event:
release
-
Statement type:
File details
Details for the file milo_cli-0.1.1-py3-none-any.whl.
File metadata
- Download URL: milo_cli-0.1.1-py3-none-any.whl
- Upload date:
- Size: 103.0 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 |
6259b881f27424f697474a24c4bbffb75f22d01850f484efa2bf8e2c0c3f341f
|
|
| MD5 |
54de23ae5cf1286d00fa4d8dd363cb80
|
|
| BLAKE2b-256 |
a561cbfd3de363cd946f3b6be2b0b83596abfe0d031dfa628ec3e11a01451b2b
|
Provenance
The following attestation bundles were made for milo_cli-0.1.1-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.1-py3-none-any.whl -
Subject digest:
6259b881f27424f697474a24c4bbffb75f22d01850f484efa2bf8e2c0c3f341f - Sigstore transparency entry: 1234808444
- Sigstore integration time:
-
Permalink:
lbliii/milo-cli@bb33a1b2194df4950e600f8b45fd521f22e70ff2 -
Branch / Tag:
refs/tags/v0.1.1 - Owner: https://github.com/lbliii
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
python-publish.yml@bb33a1b2194df4950e600f8b45fd521f22e70ff2 -
Trigger Event:
release
-
Statement type: