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/flow.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Declarative screen state machine with >> operator."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from milo._errors import ErrorCode, FlowError
|
|
10
|
+
from milo._types import Action, Quit, ReducerResult, Transition
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _default_transition(from_name: str, to_name: str) -> Transition:
|
|
14
|
+
return Transition(from_screen=from_name, to_screen=to_name, on_action="@@NAVIGATE")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class FlowScreen:
|
|
19
|
+
"""A screen in a flow with its template and reducer."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
template: str
|
|
23
|
+
reducer: Callable
|
|
24
|
+
|
|
25
|
+
def __rshift__(self, other: FlowScreen) -> Flow:
|
|
26
|
+
"""screen_a >> screen_b creates a Flow."""
|
|
27
|
+
return Flow(
|
|
28
|
+
screens=(self, other),
|
|
29
|
+
transitions=(_default_transition(self.name, other.name),),
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class FlowState:
|
|
35
|
+
"""Runtime state for a flow."""
|
|
36
|
+
|
|
37
|
+
current_screen: str
|
|
38
|
+
screen_states: dict[str, Any]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class Flow:
|
|
43
|
+
"""Declarative multi-screen state machine."""
|
|
44
|
+
|
|
45
|
+
screens: tuple[FlowScreen, ...]
|
|
46
|
+
transitions: tuple[Transition, ...]
|
|
47
|
+
|
|
48
|
+
def __rshift__(self, other: FlowScreen) -> Flow:
|
|
49
|
+
"""flow >> screen_c extends the flow."""
|
|
50
|
+
last = self.screens[-1]
|
|
51
|
+
return Flow(
|
|
52
|
+
screens=(*self.screens, other),
|
|
53
|
+
transitions=(*self.transitions, _default_transition(last.name, other.name)),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_screens(cls, *screens: FlowScreen) -> Flow:
|
|
58
|
+
"""Create a flow from an ordered sequence of screens."""
|
|
59
|
+
if len(screens) < 2:
|
|
60
|
+
raise FlowError(ErrorCode.FLW_SCREEN, "Flow requires at least 2 screens")
|
|
61
|
+
transitions = tuple(
|
|
62
|
+
_default_transition(screens[i].name, screens[i + 1].name)
|
|
63
|
+
for i in range(len(screens) - 1)
|
|
64
|
+
)
|
|
65
|
+
return cls(screens=screens, transitions=transitions)
|
|
66
|
+
|
|
67
|
+
def with_transition(self, from_screen: str, to_screen: str, *, on: str) -> Flow:
|
|
68
|
+
"""Add a custom transition."""
|
|
69
|
+
# Validate screen names
|
|
70
|
+
names = {s.name for s in self.screens}
|
|
71
|
+
if from_screen not in names:
|
|
72
|
+
raise FlowError(ErrorCode.FLW_SCREEN, f"Unknown screen: {from_screen}")
|
|
73
|
+
if to_screen not in names:
|
|
74
|
+
raise FlowError(ErrorCode.FLW_SCREEN, f"Unknown screen: {to_screen}")
|
|
75
|
+
return Flow(
|
|
76
|
+
screens=self.screens,
|
|
77
|
+
transitions=(*self.transitions, Transition(from_screen, to_screen, on)),
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
def build_reducer(self) -> Callable:
|
|
81
|
+
"""Build a combined reducer that routes actions to the current screen's reducer."""
|
|
82
|
+
screen_map = {s.name: s for s in self.screens}
|
|
83
|
+
transition_map: dict[tuple[str, str], str] = {}
|
|
84
|
+
for t in self.transitions:
|
|
85
|
+
transition_map[(t.from_screen, t.on_action)] = t.to_screen
|
|
86
|
+
|
|
87
|
+
def flow_reducer(state: FlowState | None, action: Action) -> FlowState:
|
|
88
|
+
if state is None:
|
|
89
|
+
first = self.screens[0]
|
|
90
|
+
initial_states = {s.name: s.reducer(None, Action("@@INIT")) for s in self.screens}
|
|
91
|
+
return FlowState(
|
|
92
|
+
current_screen=first.name,
|
|
93
|
+
screen_states=initial_states,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
current = state.current_screen
|
|
97
|
+
|
|
98
|
+
# Check for transitions
|
|
99
|
+
target = transition_map.get((current, action.type))
|
|
100
|
+
if target is None and action.type == "@@NAVIGATE" and action.payload:
|
|
101
|
+
target = action.payload if action.payload in screen_map else None
|
|
102
|
+
|
|
103
|
+
if target is not None and target in screen_map:
|
|
104
|
+
return FlowState(
|
|
105
|
+
current_screen=target,
|
|
106
|
+
screen_states=state.screen_states,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Route action to current screen's reducer
|
|
110
|
+
screen = screen_map.get(current)
|
|
111
|
+
if screen is None:
|
|
112
|
+
raise FlowError(ErrorCode.FLW_SCREEN, f"Unknown screen: {current}")
|
|
113
|
+
|
|
114
|
+
result = screen.reducer(state.screen_states.get(current), action)
|
|
115
|
+
|
|
116
|
+
# Unwrap Quit/ReducerResult to propagate sagas
|
|
117
|
+
sagas = ()
|
|
118
|
+
quit_signal: Quit | None = None
|
|
119
|
+
if isinstance(result, Quit):
|
|
120
|
+
quit_signal = result
|
|
121
|
+
new_screen_state = result.state
|
|
122
|
+
sagas = result.sagas
|
|
123
|
+
elif isinstance(result, ReducerResult):
|
|
124
|
+
new_screen_state = result.state
|
|
125
|
+
sagas = result.sagas
|
|
126
|
+
else:
|
|
127
|
+
new_screen_state = result
|
|
128
|
+
|
|
129
|
+
new_states = {**state.screen_states, current: new_screen_state}
|
|
130
|
+
flow_state = FlowState(
|
|
131
|
+
current_screen=current,
|
|
132
|
+
screen_states=new_states,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if quit_signal is not None:
|
|
136
|
+
return Quit(state=flow_state, code=quit_signal.code, sagas=sagas)
|
|
137
|
+
if sagas:
|
|
138
|
+
return ReducerResult(state=flow_state, sagas=sagas)
|
|
139
|
+
return flow_state
|
|
140
|
+
|
|
141
|
+
return flow_reducer
|
|
142
|
+
|
|
143
|
+
@property
|
|
144
|
+
def template_map(self) -> dict[str, str]:
|
|
145
|
+
"""Map screen names to template names."""
|
|
146
|
+
return {s.name: s.template for s in self.screens}
|
milo/form.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Form fields and form reducer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from milo._types import (
|
|
10
|
+
Action,
|
|
11
|
+
FieldSpec,
|
|
12
|
+
FieldState,
|
|
13
|
+
FieldType,
|
|
14
|
+
FormState,
|
|
15
|
+
Key,
|
|
16
|
+
SpecialKey,
|
|
17
|
+
)
|
|
18
|
+
from milo.input._platform import is_tty
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _make_initial_fields(specs: tuple[FieldSpec, ...]) -> tuple[FieldState, ...]:
|
|
22
|
+
"""Create initial field states from specs."""
|
|
23
|
+
fields = []
|
|
24
|
+
for i, spec in enumerate(specs):
|
|
25
|
+
default = spec.default if spec.default is not None else ""
|
|
26
|
+
if spec.field_type == FieldType.CONFIRM:
|
|
27
|
+
default = spec.default if spec.default is not None else False
|
|
28
|
+
elif spec.field_type == FieldType.SELECT:
|
|
29
|
+
default = spec.default if spec.default is not None else 0
|
|
30
|
+
fields.append(
|
|
31
|
+
FieldState(
|
|
32
|
+
value=default,
|
|
33
|
+
cursor=len(str(default)) if isinstance(default, str) else 0,
|
|
34
|
+
focused=(i == 0),
|
|
35
|
+
)
|
|
36
|
+
)
|
|
37
|
+
return tuple(fields)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def form_reducer(state: FormState | None, action: Action) -> FormState:
|
|
41
|
+
"""Reducer for form state."""
|
|
42
|
+
if state is None:
|
|
43
|
+
return FormState()
|
|
44
|
+
|
|
45
|
+
if state.submitted:
|
|
46
|
+
return state
|
|
47
|
+
|
|
48
|
+
if action.type == "@@INIT" and action.payload and isinstance(action.payload, tuple):
|
|
49
|
+
specs = action.payload
|
|
50
|
+
return FormState(
|
|
51
|
+
fields=_make_initial_fields(specs),
|
|
52
|
+
specs=specs,
|
|
53
|
+
active_index=0,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
if action.type != "@@KEY":
|
|
57
|
+
return state
|
|
58
|
+
|
|
59
|
+
key: Key = action.payload
|
|
60
|
+
if not isinstance(key, Key):
|
|
61
|
+
return state
|
|
62
|
+
|
|
63
|
+
idx = state.active_index
|
|
64
|
+
if idx >= len(state.fields) or idx >= len(state.specs):
|
|
65
|
+
return state
|
|
66
|
+
|
|
67
|
+
field = state.fields[idx]
|
|
68
|
+
spec = state.specs[idx]
|
|
69
|
+
|
|
70
|
+
# Tab / Enter: move to next field or submit
|
|
71
|
+
if key.name == SpecialKey.TAB or key.name == SpecialKey.ENTER:
|
|
72
|
+
if key.name == SpecialKey.ENTER and spec.field_type != FieldType.TEXT:
|
|
73
|
+
pass # Enter confirms select/confirm fields too
|
|
74
|
+
# Validate current field
|
|
75
|
+
if spec.validator:
|
|
76
|
+
ok, err = spec.validator(field.value)
|
|
77
|
+
if not ok:
|
|
78
|
+
new_field = replace(field, error=err)
|
|
79
|
+
fields = _replace_field(state.fields, idx, new_field)
|
|
80
|
+
return replace(state, fields=fields)
|
|
81
|
+
|
|
82
|
+
if idx < len(state.fields) - 1:
|
|
83
|
+
# Move to next field
|
|
84
|
+
fields = _replace_field(
|
|
85
|
+
state.fields,
|
|
86
|
+
idx,
|
|
87
|
+
replace(field, focused=False, error=""),
|
|
88
|
+
)
|
|
89
|
+
next_field = replace(state.fields[idx + 1], focused=True)
|
|
90
|
+
fields = _replace_field(fields, idx + 1, next_field)
|
|
91
|
+
return replace(state, fields=fields, active_index=idx + 1)
|
|
92
|
+
else:
|
|
93
|
+
# Submit
|
|
94
|
+
return replace(state, submitted=True)
|
|
95
|
+
|
|
96
|
+
# Shift+Tab: move to previous field
|
|
97
|
+
if key.name == SpecialKey.TAB and key.shift:
|
|
98
|
+
if idx > 0:
|
|
99
|
+
fields = _replace_field(state.fields, idx, replace(field, focused=False))
|
|
100
|
+
prev_field = replace(state.fields[idx - 1], focused=True)
|
|
101
|
+
fields = _replace_field(fields, idx - 1, prev_field)
|
|
102
|
+
return replace(state, fields=fields, active_index=idx - 1)
|
|
103
|
+
return state
|
|
104
|
+
|
|
105
|
+
# Field-type specific handling
|
|
106
|
+
match spec.field_type:
|
|
107
|
+
case FieldType.TEXT | FieldType.PASSWORD:
|
|
108
|
+
new_field = _handle_text_key(field, key)
|
|
109
|
+
case FieldType.SELECT:
|
|
110
|
+
new_field = _handle_select_key(field, key, spec)
|
|
111
|
+
case FieldType.CONFIRM:
|
|
112
|
+
new_field = _handle_confirm_key(field, key)
|
|
113
|
+
case _:
|
|
114
|
+
new_field = field
|
|
115
|
+
|
|
116
|
+
if new_field is not field:
|
|
117
|
+
fields = _replace_field(state.fields, idx, new_field)
|
|
118
|
+
return replace(state, fields=fields)
|
|
119
|
+
|
|
120
|
+
return state
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _handle_text_key(field: FieldState, key: Key) -> FieldState:
|
|
124
|
+
"""Handle keypress for text/password fields."""
|
|
125
|
+
value = str(field.value)
|
|
126
|
+
cursor = field.cursor
|
|
127
|
+
|
|
128
|
+
if key.name == SpecialKey.BACKSPACE:
|
|
129
|
+
if cursor > 0:
|
|
130
|
+
value = value[: cursor - 1] + value[cursor:]
|
|
131
|
+
cursor -= 1
|
|
132
|
+
else:
|
|
133
|
+
return field
|
|
134
|
+
elif key.name == SpecialKey.DELETE:
|
|
135
|
+
if cursor < len(value):
|
|
136
|
+
value = value[:cursor] + value[cursor + 1 :]
|
|
137
|
+
else:
|
|
138
|
+
return field
|
|
139
|
+
elif key.name == SpecialKey.LEFT:
|
|
140
|
+
cursor = max(0, cursor - 1)
|
|
141
|
+
elif key.name == SpecialKey.RIGHT:
|
|
142
|
+
cursor = min(len(value), cursor + 1)
|
|
143
|
+
elif key.name == SpecialKey.HOME:
|
|
144
|
+
cursor = 0
|
|
145
|
+
elif key.name == SpecialKey.END:
|
|
146
|
+
cursor = len(value)
|
|
147
|
+
elif key.char and key.char.isprintable() and not key.ctrl and not key.alt:
|
|
148
|
+
value = value[:cursor] + key.char + value[cursor:]
|
|
149
|
+
cursor += 1
|
|
150
|
+
else:
|
|
151
|
+
return field
|
|
152
|
+
|
|
153
|
+
return replace(field, value=value, cursor=cursor, error="")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _handle_select_key(field: FieldState, key: Key, spec: FieldSpec) -> FieldState:
|
|
157
|
+
"""Handle keypress for select fields."""
|
|
158
|
+
idx = field.selected_index
|
|
159
|
+
count = len(spec.choices)
|
|
160
|
+
|
|
161
|
+
if key.name == SpecialKey.UP:
|
|
162
|
+
idx = (idx - 1) % count if count else 0
|
|
163
|
+
elif key.name == SpecialKey.DOWN:
|
|
164
|
+
idx = (idx + 1) % count if count else 0
|
|
165
|
+
else:
|
|
166
|
+
return field
|
|
167
|
+
|
|
168
|
+
return replace(field, selected_index=idx, value=spec.choices[idx] if spec.choices else "")
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _handle_confirm_key(field: FieldState, key: Key) -> FieldState:
|
|
172
|
+
"""Handle keypress for confirm fields."""
|
|
173
|
+
if key.char in ("y", "Y"):
|
|
174
|
+
return replace(field, value=True)
|
|
175
|
+
elif key.char in ("n", "N"):
|
|
176
|
+
return replace(field, value=False)
|
|
177
|
+
elif key.name in (SpecialKey.LEFT, SpecialKey.RIGHT):
|
|
178
|
+
return replace(field, value=not field.value)
|
|
179
|
+
return field
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _replace_field(
|
|
183
|
+
fields: tuple[FieldState, ...], index: int, new: FieldState
|
|
184
|
+
) -> tuple[FieldState, ...]:
|
|
185
|
+
"""Return a new tuple with one field replaced."""
|
|
186
|
+
lst = list(fields)
|
|
187
|
+
lst[index] = new
|
|
188
|
+
return tuple(lst)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def make_form_reducer(*specs: FieldSpec, navigate_on_submit: bool = False) -> Callable:
|
|
192
|
+
"""Create a form reducer pre-loaded with field specs.
|
|
193
|
+
|
|
194
|
+
The returned reducer initializes with the given fields on @@INIT,
|
|
195
|
+
so you don't need to pass specs via action payload.
|
|
196
|
+
|
|
197
|
+
If navigate_on_submit=True, dispatches @@NAVIGATE when the form
|
|
198
|
+
is submitted — useful in flows where the next screen should appear
|
|
199
|
+
automatically after form completion.
|
|
200
|
+
"""
|
|
201
|
+
from milo._types import Put, ReducerResult
|
|
202
|
+
|
|
203
|
+
initial = FormState(
|
|
204
|
+
fields=_make_initial_fields(specs),
|
|
205
|
+
specs=specs,
|
|
206
|
+
active_index=0,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
def _navigate_saga():
|
|
210
|
+
yield Put(Action("@@NAVIGATE"))
|
|
211
|
+
|
|
212
|
+
def reducer(state: FormState | None, action: Action) -> FormState | ReducerResult:
|
|
213
|
+
if state is None:
|
|
214
|
+
return initial
|
|
215
|
+
new_state = form_reducer(state, action)
|
|
216
|
+
if navigate_on_submit and not state.submitted and new_state.submitted:
|
|
217
|
+
return ReducerResult(state=new_state, sagas=(_navigate_saga,))
|
|
218
|
+
return new_state
|
|
219
|
+
|
|
220
|
+
return reducer
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def form(
|
|
224
|
+
*specs: FieldSpec,
|
|
225
|
+
env: Any = None,
|
|
226
|
+
) -> dict[str, Any]:
|
|
227
|
+
"""Run an interactive form, return field values.
|
|
228
|
+
|
|
229
|
+
Falls back to input() if not a TTY.
|
|
230
|
+
"""
|
|
231
|
+
if not is_tty():
|
|
232
|
+
return _form_fallback(specs)
|
|
233
|
+
|
|
234
|
+
from milo.app import App
|
|
235
|
+
|
|
236
|
+
initial = FormState(
|
|
237
|
+
fields=_make_initial_fields(specs),
|
|
238
|
+
specs=specs,
|
|
239
|
+
active_index=0,
|
|
240
|
+
)
|
|
241
|
+
# Focus the first field
|
|
242
|
+
if initial.fields:
|
|
243
|
+
fields = list(initial.fields)
|
|
244
|
+
fields[0] = replace(fields[0], focused=True)
|
|
245
|
+
initial = replace(initial, fields=tuple(fields))
|
|
246
|
+
|
|
247
|
+
app = App(
|
|
248
|
+
template="form.kida",
|
|
249
|
+
reducer=form_reducer,
|
|
250
|
+
initial_state=initial,
|
|
251
|
+
env=env,
|
|
252
|
+
)
|
|
253
|
+
final: FormState = app.run()
|
|
254
|
+
return {spec.name: field.value for spec, field in zip(specs, final.fields, strict=False)}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _form_fallback(specs: tuple[FieldSpec, ...] | tuple) -> dict[str, Any]:
|
|
258
|
+
"""Non-TTY fallback using input()."""
|
|
259
|
+
values: dict[str, Any] = {}
|
|
260
|
+
for spec in specs:
|
|
261
|
+
match spec.field_type:
|
|
262
|
+
case FieldType.CONFIRM:
|
|
263
|
+
raw = input(f"{spec.label} (y/n): ").strip().lower()
|
|
264
|
+
values[spec.name] = raw in ("y", "yes")
|
|
265
|
+
case FieldType.SELECT:
|
|
266
|
+
print(f"{spec.label}:")
|
|
267
|
+
for i, choice in enumerate(spec.choices):
|
|
268
|
+
print(f" {i + 1}. {choice}")
|
|
269
|
+
raw = input("Choice: ").strip()
|
|
270
|
+
try:
|
|
271
|
+
idx = int(raw) - 1
|
|
272
|
+
values[spec.name] = spec.choices[idx]
|
|
273
|
+
except ValueError, IndexError:
|
|
274
|
+
values[spec.name] = spec.choices[0] if spec.choices else ""
|
|
275
|
+
case _:
|
|
276
|
+
values[spec.name] = input(f"{spec.label}: ").strip()
|
|
277
|
+
return values
|