milo-cli 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- milo/__init__.py +183 -0
- milo/_child.py +141 -0
- milo/_errors.py +189 -0
- milo/_protocols.py +37 -0
- milo/_types.py +234 -0
- milo/app.py +353 -0
- milo/cli.py +134 -0
- milo/commands.py +951 -0
- milo/config.py +250 -0
- milo/context.py +81 -0
- milo/dev.py +238 -0
- milo/flow.py +146 -0
- milo/form.py +277 -0
- milo/gateway.py +393 -0
- milo/groups.py +194 -0
- milo/help.py +84 -0
- milo/input/__init__.py +6 -0
- milo/input/_platform.py +81 -0
- milo/input/_reader.py +93 -0
- milo/input/_sequences.py +63 -0
- milo/llms.py +172 -0
- milo/mcp.py +299 -0
- milo/middleware.py +67 -0
- milo/observability.py +111 -0
- milo/output.py +106 -0
- milo/pipeline.py +276 -0
- milo/plugins.py +168 -0
- milo/py.typed +0 -0
- milo/registry.py +213 -0
- milo/schema.py +214 -0
- milo/state.py +229 -0
- milo/streaming.py +41 -0
- milo/templates/__init__.py +38 -0
- milo/templates/error.kida +5 -0
- milo/templates/field_confirm.kida +1 -0
- milo/templates/field_select.kida +3 -0
- milo/templates/field_text.kida +1 -0
- milo/templates/form.kida +8 -0
- milo/templates/help.kida +7 -0
- milo/templates/progress.kida +1 -0
- milo/testing/__init__.py +27 -0
- milo/testing/_mcp.py +87 -0
- milo/testing/_record.py +125 -0
- milo/testing/_replay.py +68 -0
- milo/testing/_snapshot.py +96 -0
- milo_cli-0.1.0.dist-info/METADATA +441 -0
- milo_cli-0.1.0.dist-info/RECORD +50 -0
- milo_cli-0.1.0.dist-info/WHEEL +5 -0
- milo_cli-0.1.0.dist-info/entry_points.txt +2 -0
- milo_cli-0.1.0.dist-info/top_level.txt +1 -0
milo/input/_platform.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Platform abstraction for terminal raw mode and character reading."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Generator
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@contextmanager
|
|
11
|
+
def raw_mode(fd: int | None = None) -> Generator[None]:
|
|
12
|
+
"""Context manager that puts the terminal into raw mode.
|
|
13
|
+
|
|
14
|
+
Restores original settings on exit.
|
|
15
|
+
"""
|
|
16
|
+
if fd is None:
|
|
17
|
+
fd = sys.stdin.fileno()
|
|
18
|
+
|
|
19
|
+
if sys.platform == "win32":
|
|
20
|
+
yield
|
|
21
|
+
return
|
|
22
|
+
|
|
23
|
+
import termios
|
|
24
|
+
import tty
|
|
25
|
+
|
|
26
|
+
old_settings = termios.tcgetattr(fd)
|
|
27
|
+
try:
|
|
28
|
+
tty.setraw(fd)
|
|
29
|
+
yield
|
|
30
|
+
finally:
|
|
31
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def read_char(fd: int | None = None) -> str:
|
|
35
|
+
"""Read a single character from the terminal.
|
|
36
|
+
|
|
37
|
+
Must be called within a raw_mode() context.
|
|
38
|
+
"""
|
|
39
|
+
if fd is None:
|
|
40
|
+
fd = sys.stdin.fileno()
|
|
41
|
+
|
|
42
|
+
if sys.platform == "win32":
|
|
43
|
+
import msvcrt
|
|
44
|
+
|
|
45
|
+
return msvcrt.getwch()
|
|
46
|
+
|
|
47
|
+
import os
|
|
48
|
+
|
|
49
|
+
return os.read(fd, 1).decode("utf-8", errors="replace")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_available(fd: int | None = None, max_bytes: int = 16) -> str:
|
|
53
|
+
"""Read all immediately available bytes (non-blocking).
|
|
54
|
+
|
|
55
|
+
Used to consume multi-byte escape sequences after the initial escape.
|
|
56
|
+
"""
|
|
57
|
+
if fd is None:
|
|
58
|
+
fd = sys.stdin.fileno()
|
|
59
|
+
|
|
60
|
+
import os
|
|
61
|
+
import select
|
|
62
|
+
|
|
63
|
+
result = ""
|
|
64
|
+
for _ in range(max_bytes):
|
|
65
|
+
ready, _, _ = select.select([fd], [], [], 0)
|
|
66
|
+
if not ready:
|
|
67
|
+
break
|
|
68
|
+
result += os.read(fd, 1).decode("utf-8", errors="replace")
|
|
69
|
+
return result
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_tty(fd: int | None = None) -> bool:
|
|
73
|
+
"""Check if fd is connected to a terminal."""
|
|
74
|
+
if fd is None:
|
|
75
|
+
try:
|
|
76
|
+
fd = sys.stdin.fileno()
|
|
77
|
+
except AttributeError, ValueError, OSError:
|
|
78
|
+
return False
|
|
79
|
+
import os
|
|
80
|
+
|
|
81
|
+
return os.isatty(fd)
|
milo/input/_reader.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""KeyReader — iterator that yields Key objects from terminal input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from types import TracebackType
|
|
8
|
+
|
|
9
|
+
from milo._errors import ErrorCode, InputError
|
|
10
|
+
from milo._types import Key, SpecialKey
|
|
11
|
+
from milo.input._platform import is_tty, raw_mode, read_available, read_char
|
|
12
|
+
from milo.input._sequences import CTRL_CHARS, ESCAPE_SEQUENCES
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class KeyReader:
|
|
16
|
+
"""Iterator that yields Key objects from terminal input.
|
|
17
|
+
|
|
18
|
+
Usage::
|
|
19
|
+
|
|
20
|
+
with KeyReader() as keys:
|
|
21
|
+
for key in keys:
|
|
22
|
+
if key.name == SpecialKey.ESCAPE:
|
|
23
|
+
break
|
|
24
|
+
print(key)
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, fd: int | None = None) -> None:
|
|
28
|
+
self._fd = fd if fd is not None else sys.stdin.fileno()
|
|
29
|
+
self._raw_ctx = None
|
|
30
|
+
self._closed = False
|
|
31
|
+
|
|
32
|
+
def __enter__(self) -> KeyReader:
|
|
33
|
+
if not is_tty(self._fd):
|
|
34
|
+
raise InputError(
|
|
35
|
+
ErrorCode.INP_RAW_MODE,
|
|
36
|
+
"Cannot read keys: stdin is not a TTY",
|
|
37
|
+
)
|
|
38
|
+
self._raw_ctx = raw_mode(self._fd)
|
|
39
|
+
self._raw_ctx.__enter__()
|
|
40
|
+
return self
|
|
41
|
+
|
|
42
|
+
def __exit__(
|
|
43
|
+
self,
|
|
44
|
+
exc_type: type[BaseException] | None,
|
|
45
|
+
exc_val: BaseException | None,
|
|
46
|
+
exc_tb: TracebackType | None,
|
|
47
|
+
) -> None:
|
|
48
|
+
self._closed = True
|
|
49
|
+
if self._raw_ctx is not None:
|
|
50
|
+
self._raw_ctx.__exit__(exc_type, exc_val, exc_tb)
|
|
51
|
+
|
|
52
|
+
def __iter__(self) -> Iterator[Key]:
|
|
53
|
+
return self
|
|
54
|
+
|
|
55
|
+
def __next__(self) -> Key:
|
|
56
|
+
if self._closed:
|
|
57
|
+
raise StopIteration
|
|
58
|
+
return self.read_key()
|
|
59
|
+
|
|
60
|
+
def read_key(self) -> Key:
|
|
61
|
+
"""Read and return the next keypress."""
|
|
62
|
+
try:
|
|
63
|
+
ch = read_char(self._fd)
|
|
64
|
+
except (OSError, ValueError) as e:
|
|
65
|
+
raise InputError(ErrorCode.INP_READ, str(e)) from e
|
|
66
|
+
|
|
67
|
+
# Escape sequence
|
|
68
|
+
if ch == "\x1b":
|
|
69
|
+
rest = read_available(self._fd)
|
|
70
|
+
seq = "\x1b" + rest
|
|
71
|
+
if seq in ESCAPE_SEQUENCES:
|
|
72
|
+
return ESCAPE_SEQUENCES[seq]
|
|
73
|
+
if not rest:
|
|
74
|
+
return Key(name=SpecialKey.ESCAPE)
|
|
75
|
+
# Alt+char
|
|
76
|
+
if len(rest) == 1 and rest.isprintable():
|
|
77
|
+
return Key(char=rest, alt=True)
|
|
78
|
+
return Key(name=SpecialKey.ESCAPE)
|
|
79
|
+
|
|
80
|
+
# Known single-char sequences (tab, enter, backspace)
|
|
81
|
+
if ch in ESCAPE_SEQUENCES:
|
|
82
|
+
return ESCAPE_SEQUENCES[ch]
|
|
83
|
+
|
|
84
|
+
# Ctrl+letter
|
|
85
|
+
code = ord(ch)
|
|
86
|
+
if code in CTRL_CHARS:
|
|
87
|
+
return Key(char=CTRL_CHARS[code], ctrl=True)
|
|
88
|
+
|
|
89
|
+
# Regular printable character
|
|
90
|
+
if ch.isprintable():
|
|
91
|
+
return Key(char=ch)
|
|
92
|
+
|
|
93
|
+
return Key(char=ch)
|
milo/input/_sequences.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Frozen escape sequence lookup table."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from milo._types import Key, SpecialKey
|
|
6
|
+
|
|
7
|
+
# Map escape sequences to Key objects
|
|
8
|
+
ESCAPE_SEQUENCES: dict[str, Key] = {
|
|
9
|
+
# Arrow keys
|
|
10
|
+
"\x1b[A": Key(name=SpecialKey.UP),
|
|
11
|
+
"\x1b[B": Key(name=SpecialKey.DOWN),
|
|
12
|
+
"\x1b[C": Key(name=SpecialKey.RIGHT),
|
|
13
|
+
"\x1b[D": Key(name=SpecialKey.LEFT),
|
|
14
|
+
# Home / End
|
|
15
|
+
"\x1b[H": Key(name=SpecialKey.HOME),
|
|
16
|
+
"\x1b[F": Key(name=SpecialKey.END),
|
|
17
|
+
"\x1b[1~": Key(name=SpecialKey.HOME),
|
|
18
|
+
"\x1b[4~": Key(name=SpecialKey.END),
|
|
19
|
+
# Page Up / Down
|
|
20
|
+
"\x1b[5~": Key(name=SpecialKey.PAGE_UP),
|
|
21
|
+
"\x1b[6~": Key(name=SpecialKey.PAGE_DOWN),
|
|
22
|
+
# Insert / Delete
|
|
23
|
+
"\x1b[2~": Key(name=SpecialKey.INSERT),
|
|
24
|
+
"\x1b[3~": Key(name=SpecialKey.DELETE),
|
|
25
|
+
# Function keys
|
|
26
|
+
"\x1bOP": Key(name=SpecialKey.F1),
|
|
27
|
+
"\x1bOQ": Key(name=SpecialKey.F2),
|
|
28
|
+
"\x1bOR": Key(name=SpecialKey.F3),
|
|
29
|
+
"\x1bOS": Key(name=SpecialKey.F4),
|
|
30
|
+
"\x1b[15~": Key(name=SpecialKey.F5),
|
|
31
|
+
"\x1b[17~": Key(name=SpecialKey.F6),
|
|
32
|
+
"\x1b[18~": Key(name=SpecialKey.F7),
|
|
33
|
+
"\x1b[19~": Key(name=SpecialKey.F8),
|
|
34
|
+
"\x1b[20~": Key(name=SpecialKey.F9),
|
|
35
|
+
"\x1b[21~": Key(name=SpecialKey.F10),
|
|
36
|
+
"\x1b[23~": Key(name=SpecialKey.F11),
|
|
37
|
+
"\x1b[24~": Key(name=SpecialKey.F12),
|
|
38
|
+
# Shift+arrow
|
|
39
|
+
"\x1b[1;2A": Key(name=SpecialKey.UP, shift=True),
|
|
40
|
+
"\x1b[1;2B": Key(name=SpecialKey.DOWN, shift=True),
|
|
41
|
+
"\x1b[1;2C": Key(name=SpecialKey.RIGHT, shift=True),
|
|
42
|
+
"\x1b[1;2D": Key(name=SpecialKey.LEFT, shift=True),
|
|
43
|
+
# Alt+arrow
|
|
44
|
+
"\x1b[1;3A": Key(name=SpecialKey.UP, alt=True),
|
|
45
|
+
"\x1b[1;3B": Key(name=SpecialKey.DOWN, alt=True),
|
|
46
|
+
"\x1b[1;3C": Key(name=SpecialKey.RIGHT, alt=True),
|
|
47
|
+
"\x1b[1;3D": Key(name=SpecialKey.LEFT, alt=True),
|
|
48
|
+
# Ctrl+arrow
|
|
49
|
+
"\x1b[1;5A": Key(name=SpecialKey.UP, ctrl=True),
|
|
50
|
+
"\x1b[1;5B": Key(name=SpecialKey.DOWN, ctrl=True),
|
|
51
|
+
"\x1b[1;5C": Key(name=SpecialKey.RIGHT, ctrl=True),
|
|
52
|
+
"\x1b[1;5D": Key(name=SpecialKey.LEFT, ctrl=True),
|
|
53
|
+
# Tab and Enter
|
|
54
|
+
"\t": Key(name=SpecialKey.TAB),
|
|
55
|
+
"\r": Key(name=SpecialKey.ENTER),
|
|
56
|
+
"\n": Key(name=SpecialKey.ENTER),
|
|
57
|
+
# Backspace variants
|
|
58
|
+
"\x7f": Key(name=SpecialKey.BACKSPACE),
|
|
59
|
+
"\x08": Key(name=SpecialKey.BACKSPACE),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
# Control character to ctrl+letter mapping (ctrl+a = 0x01, ctrl+z = 0x1a)
|
|
63
|
+
CTRL_CHARS: dict[int, str] = {i: chr(i + 96) for i in range(1, 27)}
|
milo/llms.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""llms.txt generation from CLI command definitions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from milo.commands import CLI, CommandDef, LazyCommandDef
|
|
9
|
+
from milo.groups import Group
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def generate_llms_txt(cli: CLI) -> str:
|
|
13
|
+
"""Generate llms.txt content from a CLI's registered commands.
|
|
14
|
+
|
|
15
|
+
Follows the llms.txt specification (https://llmstxt.org/).
|
|
16
|
+
Output is a curated Markdown document that helps AI agents
|
|
17
|
+
discover what the CLI can do. Groups produce nested headings.
|
|
18
|
+
"""
|
|
19
|
+
lines: list[str] = []
|
|
20
|
+
|
|
21
|
+
# Title
|
|
22
|
+
lines.append(f"# {cli.name}")
|
|
23
|
+
lines.append("")
|
|
24
|
+
|
|
25
|
+
# Description
|
|
26
|
+
if cli.description:
|
|
27
|
+
lines.append(f"> {cli.description}")
|
|
28
|
+
lines.append("")
|
|
29
|
+
|
|
30
|
+
# Version
|
|
31
|
+
if cli.version:
|
|
32
|
+
lines.append(f"Version: {cli.version}")
|
|
33
|
+
lines.append("")
|
|
34
|
+
|
|
35
|
+
# Group commands by tag
|
|
36
|
+
tagged: dict[str, list[CommandDef | LazyCommandDef]] = {}
|
|
37
|
+
untagged: list[CommandDef | LazyCommandDef] = []
|
|
38
|
+
|
|
39
|
+
for cmd in cli.commands.values():
|
|
40
|
+
if cmd.hidden:
|
|
41
|
+
continue
|
|
42
|
+
if cmd.tags:
|
|
43
|
+
for tag in cmd.tags:
|
|
44
|
+
tagged.setdefault(tag, []).append(cmd)
|
|
45
|
+
else:
|
|
46
|
+
untagged.append(cmd)
|
|
47
|
+
|
|
48
|
+
# Untagged commands
|
|
49
|
+
if untagged:
|
|
50
|
+
lines.append("## Commands")
|
|
51
|
+
lines.append("")
|
|
52
|
+
lines.extend(_format_command(cmd) for cmd in untagged)
|
|
53
|
+
lines.append("")
|
|
54
|
+
|
|
55
|
+
# Tagged groups
|
|
56
|
+
for tag, cmds in sorted(tagged.items()):
|
|
57
|
+
title = tag.replace("-", " ").replace("_", " ").title()
|
|
58
|
+
lines.append(f"## {title}")
|
|
59
|
+
lines.append("")
|
|
60
|
+
lines.extend(_format_command(cmd) for cmd in cmds)
|
|
61
|
+
lines.append("")
|
|
62
|
+
|
|
63
|
+
# Command groups
|
|
64
|
+
for group in cli.groups.values():
|
|
65
|
+
if group.hidden:
|
|
66
|
+
continue
|
|
67
|
+
_format_group(group, lines, depth=2)
|
|
68
|
+
|
|
69
|
+
# Resources section
|
|
70
|
+
resources = cli.walk_resources()
|
|
71
|
+
if resources:
|
|
72
|
+
lines.append("## Resources")
|
|
73
|
+
lines.append("")
|
|
74
|
+
for _uri, res in resources:
|
|
75
|
+
lines.append(f"- **{res.uri}** ({res.mime_type}): {res.description}")
|
|
76
|
+
lines.append("")
|
|
77
|
+
|
|
78
|
+
# Prompts section
|
|
79
|
+
prompts = cli.walk_prompts()
|
|
80
|
+
if prompts:
|
|
81
|
+
lines.append("## Prompts")
|
|
82
|
+
lines.append("")
|
|
83
|
+
for _name, p in prompts:
|
|
84
|
+
args_str = ""
|
|
85
|
+
if p.arguments:
|
|
86
|
+
arg_names = [a.get("name", "?") for a in p.arguments]
|
|
87
|
+
args_str = f" ({', '.join(arg_names)})"
|
|
88
|
+
lines.append(f"- **{p.name}**{args_str}: {p.description}")
|
|
89
|
+
lines.append("")
|
|
90
|
+
|
|
91
|
+
# Workflows section (heuristic relationship detection)
|
|
92
|
+
workflows = _detect_workflows(cli)
|
|
93
|
+
if workflows:
|
|
94
|
+
lines.append("## Workflows")
|
|
95
|
+
lines.append("")
|
|
96
|
+
lines.extend(f"- {desc}" for desc in workflows)
|
|
97
|
+
lines.append("")
|
|
98
|
+
|
|
99
|
+
return "\n".join(lines)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _format_group(group: Group, lines: list[str], depth: int) -> None:
|
|
103
|
+
"""Format a command group as a section with nested headings."""
|
|
104
|
+
heading = "#" * depth
|
|
105
|
+
title = group.description or group.name.replace("-", " ").replace("_", " ").title()
|
|
106
|
+
lines.append(f"{heading} {title}")
|
|
107
|
+
lines.append("")
|
|
108
|
+
|
|
109
|
+
for cmd in group.commands.values():
|
|
110
|
+
if cmd.hidden:
|
|
111
|
+
continue
|
|
112
|
+
lines.append(_format_command(cmd))
|
|
113
|
+
if group.commands:
|
|
114
|
+
lines.append("")
|
|
115
|
+
|
|
116
|
+
for sub_group in group.groups.values():
|
|
117
|
+
if sub_group.hidden:
|
|
118
|
+
continue
|
|
119
|
+
_format_group(sub_group, lines, depth=depth + 1)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _format_command(cmd: CommandDef) -> str:
|
|
123
|
+
"""Format a single command as an llms.txt entry."""
|
|
124
|
+
parts = [f"- **{cmd.name}**"]
|
|
125
|
+
|
|
126
|
+
if cmd.aliases:
|
|
127
|
+
parts.append(f" ({', '.join(cmd.aliases)})")
|
|
128
|
+
|
|
129
|
+
parts.append(f": {cmd.description}" if cmd.description else "")
|
|
130
|
+
|
|
131
|
+
# Parameter summary
|
|
132
|
+
props = cmd.schema.get("properties", {})
|
|
133
|
+
required = set(cmd.schema.get("required", []))
|
|
134
|
+
if props:
|
|
135
|
+
params = []
|
|
136
|
+
for name, schema in props.items():
|
|
137
|
+
param_type = schema.get("type", "string")
|
|
138
|
+
if name in required:
|
|
139
|
+
params.append(f"`--{name}` ({param_type}, required)")
|
|
140
|
+
else:
|
|
141
|
+
params.append(f"`--{name}` ({param_type})")
|
|
142
|
+
parts.append("\n Parameters: " + ", ".join(params))
|
|
143
|
+
|
|
144
|
+
# Examples
|
|
145
|
+
examples = getattr(cmd, "examples", ())
|
|
146
|
+
if examples:
|
|
147
|
+
parts.append("\n Examples:")
|
|
148
|
+
for ex in examples:
|
|
149
|
+
args_str = " ".join(f"--{k} {v}" for k, v in ex.items())
|
|
150
|
+
parts.append(f"\n `{cmd.name} {args_str}`")
|
|
151
|
+
|
|
152
|
+
return "".join(parts)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _detect_workflows(cli: CLI) -> list[str]:
|
|
156
|
+
"""Heuristically detect command workflows via output→input parameter overlap."""
|
|
157
|
+
commands = list(cli.walk_commands())
|
|
158
|
+
workflows: list[str] = []
|
|
159
|
+
|
|
160
|
+
for i, (name_a, cmd_a) in enumerate(commands):
|
|
161
|
+
if cmd_a.hidden:
|
|
162
|
+
continue
|
|
163
|
+
props_a = set(cmd_a.schema.get("properties", {}).keys())
|
|
164
|
+
for name_b, cmd_b in commands[i + 1 :]:
|
|
165
|
+
if cmd_b.hidden:
|
|
166
|
+
continue
|
|
167
|
+
props_b = set(cmd_b.schema.get("properties", {}).keys())
|
|
168
|
+
overlap = props_a & props_b
|
|
169
|
+
if overlap:
|
|
170
|
+
workflows.append(f"`{name_a}` → `{name_b}` (shared: {', '.join(sorted(overlap))})")
|
|
171
|
+
|
|
172
|
+
return workflows
|