Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ When you change something in milo-cli, the blast radius is:
- **Terminal rendering bugs** → Alternate screen buffer not restored, cursor left off, raw mode leaked. Harm: the user's terminal is broken after our CLI exits. Happens silently unless you actually run the app to completion.
- **Pipeline orchestration bugs** → Phase retries, dependency cycles, output capture. These power real deployment tooling for downstream consumers. Harm: a phase appears to succeed but didn't; or a deploy hangs forever.
- **Startup-cost regressions** → The lazy-import contract is load-bearing. A stray top-level import in `__init__.py` adds latency to every CLI invocation in every downstream project.
- **Scaffold or `milo verify` regressions** → `milo new` and `milo verify` are the front door and self-diagnosis tool agents use to bootstrap and debug. A broken scaffold produces CLIs that fail their own tests; a broken verify tells agents a correctly-built CLI is wrong, or a broken one is fine. Harm: agent onboarding stalls or agents ship bad CLIs with a green check. Same severity class as schema-generation bugs — both corrupt the agent-facing contract.

milo-cli is 0.2.x / alpha but has real consumers (Pounce, DORI evaluating). Calibrate accordingly — the API can still move, but not carelessly.

Expand Down
50 changes: 48 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,14 @@ Milo is a Python framework where every CLI is simultaneously a terminal app, a c

## Installation

**Requires Python 3.14+.** If you don't have it: `uv python install 3.14`.

```bash
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
Expand Down Expand Up @@ -144,6 +144,52 @@ Requires Python 3.14+

---

## Examples Index

Pick the example closest to your use case, copy its `app.py`, and adapt. Every directory below contains a runnable `app.py` plus a focused README.

**CLIs (typed function → CLI + MCP + llms.txt)**

| What you want to build | Example | Key APIs |
|---|---|---|
| The simplest possible CLI | [examples/greet](examples/greet) | `CLI`, `@cli.command` |
| Dual-mode CLI ↔ MCP server (flagship) | [examples/deploy](examples/deploy) | `Annotated`, `MinLen`, `Context`, `Progress`, `--mcp` |
| Context injection, logging, progress, confirms | [examples/ctxdemo](examples/ctxdemo) | `Context`, `ctx.info`, `ctx.progress`, `ctx.confirm` |
| Nested command groups (`app repo list`) | [examples/groups](examples/groups) | `cli.group()`, `walk_commands` |
| Fast startup via deferred imports | [examples/lazyapp](examples/lazyapp) | `cli.lazy_command()` |
| Production CLI with hooks, completions, doctor | [examples/devtool](examples/devtool) | `run_doctor`, `before_run`/`after_run`, did-you-mean, completions |
| AI-native CLI surfacing tools + resources | [examples/taskman](examples/taskman) | `@command`, `@resource`, `--format`, `--llms-txt`, `--mcp` |

**Configuration, plugins, pipelines**

| What you want to build | Example | Key APIs |
|---|---|---|
| TOML config with profiles + overlays | [examples/configapp](examples/configapp) | `Config`, `ConfigSpec`, `Config.load`, `Config.validate` |
| Plugin system with hooks + listeners | [examples/pluggable](examples/pluggable) | `HookRegistry`, `define`, `on`, `invoke` |
| Multi-phase pipeline with deps + retries | [examples/buildpipe](examples/buildpipe) | `Pipeline`, `Phase`, `PhasePolicy`, `>>` |

**Interactive TUIs (`App` + reducer)**

| What you want to build | Example | Key APIs |
|---|---|---|
| The simplest TUI | [examples/counter](examples/counter) | `App.from_dir`, reducer combinators |
| Modal input with derived filtering | [examples/todo](examples/todo) | tuple state, `quit_on`, derived views |
| Tick-driven animation | [examples/stopwatch](examples/stopwatch) | `tick_rate`, `@@TICK`, `quit_on` |
| Scrollable viewport with saga I/O | [examples/filepicker](examples/filepicker) | viewport, sagas, frozen tuples |
| Multi-screen flow with forms | [examples/wizard](examples/wizard) | `Flow`, `FlowScreen`, `make_form_reducer`, `FieldSpec` |

**Async work (sagas + Cmd pattern)**

| What you want to build | Example | Key APIs |
|---|---|---|
| Sagas for async side effects | [examples/fetcher](examples/fetcher) | `Call`, `Put`, `Select`, `Retry` |
| Parallel concurrent work | [examples/downloader](examples/downloader) | `Fork`, `Call`, `Delay`, `Timeout` |
| Bubbletea-style Cmd thunks | [examples/spinner](examples/spinner) | `Cmd`, `Batch`, `TickCmd`, `ViewState` |

> Don't see your use case? Run `milo new <name>` to scaffold a fresh CLI with tests, then `milo verify app.py` to confirm it works.

---

## Usage

<details>
Expand Down
1 change: 1 addition & 0 deletions changelog.d/+agent-affordances.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Agent-native affordances: `milo new <name>` scaffold (app.py, tests, conftest, README), `milo verify <path>` six-check self-diagnosis (imports, CLI located, commands registered, schemas generate, in-process MCP list, subprocess MCP transport), `function_to_schema(..., warn_missing_docs=True)` surfacing undocumented typed params, README examples index with drift lint, and a Python 3.14+ preflight on `milo` with an actionable install hint instead of ImportError.
37 changes: 37 additions & 0 deletions docs/agent-quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ If something in this doc no longer works, that's the bug — open an issue.
- `uv` installed (`curl -LsSf https://astral.sh/uv/install.sh | sh`).
- This repo cloned or `milo-cli` installed (`uv add milo-cli`).

## Step 0 — Scaffold (optional; skip if writing manually)

```bash
uv run milo new my_cli
cd my_cli
```

Produces `app.py`, `tests/test_app.py`, `conftest.py`, and a `README.md` — the
same shape this doc walks through. Scaffold names must be lowercase with
underscores (`my_cli`, not `My-CLI`). If the directory exists, the command
refuses to overwrite; pick another name or delete the old one.

## Step 1 — Write the function

```python
Expand Down Expand Up @@ -113,6 +125,31 @@ You should see `my_cli` listed with `greet` as a tool. Call it:
Expected result: Claude calls the tool, the tool returns `"Hello, Bob!"`,
Claude echoes it back.

## Step 6 — Self-diagnose with `milo verify`

Before registering with Claude (or any time you break something), run:

```bash
uv run milo verify my_cli/app.py
```

All six checks should pass:

```
✓ imports: loaded app.py
✓ cli_located: found CLI instance (name='my_cli')
✓ commands_registered: 1 command(s) registered
✓ schemas_generate: 1 schema(s) generated; all params documented
✓ mcp_list_tools: 1 tool(s) listed with valid inputSchema
✓ mcp_transport: subprocess handshake succeeded; 1 tool(s) over JSON-RPC
```

A `⚠ schemas_generate` row listing `parameter 'X' has no description` means a
typed parameter is missing an `Args:` entry (or `Annotated[..., Description(...)]`).
A `✗` row is a failure — read the details and fix before continuing.

`milo verify` exits 0 on warnings, nonzero on failures. Wire it into CI.

## When things go wrong

| Symptom | Likely cause | Fix |
Expand Down
9 changes: 8 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@ Issues = "https://github.com/lbliii/milo-cli/issues"
where = ["src"]

[tool.setuptools.package-data]
milo = ["templates/*.kida", "templates/components/*.kida", "py.typed"]
milo = [
"templates/*.kida",
"templates/components/*.kida",
"_scaffold/default/*",
"_scaffold/default/**/*",
"py.typed",
]

[tool.pytest.ini_options]
testpaths = ["tests"]
Expand Down Expand Up @@ -125,6 +131,7 @@ ignore = [
"__init__.py" = ["F401"]
"tests/**/*.py" = ["S101", "S108", "S110", "S604", "SIM117", "N806", "ARG", "T201"]
"examples/**/*.py" = ["S101", "S108", "S110", "S604", "SIM117", "N806", "ARG", "T201", "E402"]
"src/milo/_scaffold/default/**/*.py" = ["S101", "I001", "F401", "ARG", "T201", "E402"]
"src/milo/_child.py" = ["S101"] # asserts for type narrowing on subprocess pipes
"src/milo/testing/*.py" = ["S101", "ARG"] # test helpers use assert and may have unused args
"src/milo/version_check.py" = ["S110", "S310"] # best-effort cache reads + known PyPI URL
Expand Down
55 changes: 55 additions & 0 deletions src/milo/_scaffold/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Project scaffolding for `milo new`.

Templates live under ``default/`` and are copied with ``{{name}}`` substitution.
No template engine — a plain ``str.replace`` keeps scaffolding zero-dep and
auditable.
"""

from __future__ import annotations

import re
from pathlib import Path

_TEMPLATE_DIR = Path(__file__).parent / "default"
_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")


class ScaffoldError(Exception):
"""Raised when a scaffold cannot be created."""


def scaffold(name: str, target_dir: Path) -> Path:
"""Create a new milo CLI project at ``target_dir / name``.

Args:
name: Project name. Must match ``^[a-z][a-z0-9_]*$`` so it works
both as a directory name and as a Python identifier.
target_dir: Parent directory in which the project dir is created.

Returns:
The created project directory path.

Raises:
ScaffoldError: If ``name`` is invalid or the target path exists.
"""
if not _NAME_RE.match(name):
raise ScaffoldError(
f"Invalid project name '{name}'. "
f"Use lowercase letters, digits, and underscores; start with a letter."
)

project_dir = target_dir / name
if project_dir.exists():
raise ScaffoldError(f"Refusing to overwrite existing path: {project_dir}")

for src in _TEMPLATE_DIR.rglob("*"):
rel = src.relative_to(_TEMPLATE_DIR)
dst = project_dir / rel
if src.is_dir():
dst.mkdir(parents=True, exist_ok=True)
continue
dst.parent.mkdir(parents=True, exist_ok=True)
content = src.read_text(encoding="utf-8")
dst.write_text(content.replace("{{name}}", name), encoding="utf-8")

return project_dir
35 changes: 35 additions & 0 deletions src/milo/_scaffold/default/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# {{name}}

A milo CLI: one typed function, three protocols (CLI, MCP server, llms.txt).

## Run

```bash
# CLI
uv run python app.py greet --name Alice
uv run python app.py greet --name Alice --loud

# llms.txt (agent-readable catalog)
uv run python app.py --llms-txt

# MCP server (stdin/stdout JSON-RPC, for Claude et al.)
uv run python app.py --mcp
```

## Test

```bash
uv run pytest tests/ -v
```

The test file covers three layers — schema, direct dispatch, MCP dispatch.
Add commands by adding `@cli.command(...)` functions to `app.py`, then add
matching tests in `tests/test_app.py`.

## Register with Claude

```bash
claude mcp add {{name}} -- uv run python /absolute/path/to/app.py --mcp
```

See the milo agent quickstart for the full walkthrough.
29 changes: 29 additions & 0 deletions src/milo/_scaffold/default/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
"""{{name}} — a milo CLI scaffolded by `milo new`.

Run modes:
uv run python app.py greet --name Alice
uv run python app.py --llms-txt
uv run python app.py --mcp
"""

from __future__ import annotations

from milo import CLI

cli = CLI(name="{{name}}", description="What it does", version="0.1")


@cli.command("greet", description="Return a greeting")
def greet(name: str, loud: bool = False) -> str:
"""Greet someone by name.

Args:
name: The person to greet.
loud: If true, SHOUT.
"""
message = f"Hello, {name}!"
return message.upper() if loud else message


if __name__ == "__main__":
cli.run()
8 changes: 8 additions & 0 deletions src/milo/_scaffold/default/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"""pytest configuration: make app.py importable from tests/."""

from __future__ import annotations

import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
Empty file.
65 changes: 65 additions & 0 deletions src/milo/_scaffold/default/tests/test_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Tests for {{name}} — schema, direct dispatch, MCP dispatch.

Three layers cover the common regression surface:
1. Schema — `function_to_schema(greet)` matches the function signature.
2. Direct — `cli.invoke([...])` returns the expected output.
3. MCP — `_call_tool(cli, {...})` returns the expected response and,
on error, structured `errorData` with `argument` context.
"""

from __future__ import annotations

import sys

import pytest

from app import cli, greet

from milo.mcp import _call_tool, _list_tools
from milo.schema import function_to_schema


class TestSchema:
def test_generated_schema_matches_signature(self):
schema = function_to_schema(greet)
assert schema["type"] == "object"
assert schema["properties"]["name"]["type"] == "string"
assert schema["properties"]["loud"]["type"] == "boolean"
assert schema["required"] == ["name"]
assert schema["properties"]["loud"]["default"] is False

def test_tool_appears_in_list_tools(self):
tools = _list_tools(cli)
assert "greet" in [t["name"] for t in tools]


class TestDirectDispatch:
def test_invoke_returns_string(self):
result = cli.invoke(["greet", "--name", "Alice"])
assert result.exit_code == 0
assert "Hello, Alice!" in result.output

def test_invoke_with_loud_flag(self):
result = cli.invoke(["greet", "--name", "Alice", "--loud"])
assert result.exit_code == 0
assert "HELLO, ALICE!" in result.output

def test_call_raw_returns_plain_value(self):
assert cli.call_raw("greet", name="Bob") == "Hello, Bob!"


class TestMCPDispatch:
def test_call_tool_returns_content(self):
result = _call_tool(cli, {"name": "greet", "arguments": {"name": "Agent"}})
assert result["content"][0]["text"] == "Hello, Agent!"
assert "isError" not in result

def test_call_tool_missing_required_arg_returns_argument_context(self):
result = _call_tool(cli, {"name": "greet", "arguments": {}})
assert result["isError"] is True
assert result["errorData"]["argument"] == "name"
assert result["errorData"]["reason"] == "missing_required_argument"


if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-v"]))
Loading
Loading