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.
@@ -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)
@@ -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