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/pipeline.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Pipeline orchestration with observable state through the Store/saga system."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass, replace
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from milo._types import Action, Call, Fork, Put
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Data types
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True, slots=True)
|
|
18
|
+
class Phase:
|
|
19
|
+
"""A named pipeline phase."""
|
|
20
|
+
|
|
21
|
+
name: str
|
|
22
|
+
handler: Callable[..., Any]
|
|
23
|
+
description: str = ""
|
|
24
|
+
depends_on: tuple[str, ...] = ()
|
|
25
|
+
parallel: bool = False
|
|
26
|
+
weight: int = 1
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True, slots=True)
|
|
30
|
+
class PhaseStatus:
|
|
31
|
+
"""Runtime status of a single phase."""
|
|
32
|
+
|
|
33
|
+
name: str
|
|
34
|
+
status: str = "pending" # pending, running, completed, failed, skipped
|
|
35
|
+
started_at: float = 0.0
|
|
36
|
+
elapsed: float = 0.0
|
|
37
|
+
error: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class PipelineState:
|
|
42
|
+
"""Observable state for a running pipeline."""
|
|
43
|
+
|
|
44
|
+
name: str = ""
|
|
45
|
+
phases: tuple[PhaseStatus, ...] = ()
|
|
46
|
+
current_phase: str = ""
|
|
47
|
+
started_at: float = 0.0
|
|
48
|
+
elapsed: float = 0.0
|
|
49
|
+
progress: float = 0.0
|
|
50
|
+
status: str = "pending" # pending, running, completed, failed
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ---------------------------------------------------------------------------
|
|
54
|
+
# Pipeline action types
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
PIPELINE_START = "@@PIPELINE_START"
|
|
59
|
+
PIPELINE_COMPLETE = "@@PIPELINE_COMPLETE"
|
|
60
|
+
PHASE_START = "@@PHASE_START"
|
|
61
|
+
PHASE_COMPLETE = "@@PHASE_COMPLETE"
|
|
62
|
+
PHASE_FAILED = "@@PHASE_FAILED"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------
|
|
66
|
+
# Pipeline class
|
|
67
|
+
# ---------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Pipeline:
|
|
71
|
+
"""Declarative build pipeline that executes through the Store.
|
|
72
|
+
|
|
73
|
+
Usage::
|
|
74
|
+
|
|
75
|
+
pipeline = Pipeline(
|
|
76
|
+
"build",
|
|
77
|
+
Phase("discover", handler=discover),
|
|
78
|
+
Phase("parse", handler=parse, depends_on=("discover",)),
|
|
79
|
+
Phase("render", handler=render, depends_on=("parse",), parallel=True),
|
|
80
|
+
Phase("assets", handler=assets, depends_on=("parse",), parallel=True),
|
|
81
|
+
Phase("write", handler=write, depends_on=("render", "assets")),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
# Extend with >>
|
|
85
|
+
pipeline = pipeline >> Phase("health", handler=check)
|
|
86
|
+
|
|
87
|
+
The pipeline generates a saga and reducer that work with the Store
|
|
88
|
+
for observable, testable execution.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
def __init__(self, name: str, *phases: Phase) -> None:
|
|
92
|
+
self.name = name
|
|
93
|
+
self.phases = list(phases)
|
|
94
|
+
|
|
95
|
+
def __rshift__(self, phase: Phase) -> Pipeline:
|
|
96
|
+
"""Extend the pipeline: ``pipeline >> Phase(...)``."""
|
|
97
|
+
new = Pipeline(self.name, *self.phases, phase)
|
|
98
|
+
return new
|
|
99
|
+
|
|
100
|
+
def build_reducer(self) -> Callable:
|
|
101
|
+
"""Generate a reducer that handles pipeline state transitions."""
|
|
102
|
+
name = self.name
|
|
103
|
+
phase_names = tuple(p.name for p in self.phases)
|
|
104
|
+
total_weight = sum(p.weight for p in self.phases)
|
|
105
|
+
weight_map = {p.name: p.weight for p in self.phases}
|
|
106
|
+
|
|
107
|
+
def reducer(state: PipelineState | None, action: Action) -> PipelineState:
|
|
108
|
+
if action.type == "@@INIT":
|
|
109
|
+
return PipelineState(
|
|
110
|
+
name=name,
|
|
111
|
+
phases=tuple(PhaseStatus(name=n) for n in phase_names),
|
|
112
|
+
status="pending",
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if state is None:
|
|
116
|
+
return PipelineState(name=name)
|
|
117
|
+
|
|
118
|
+
if action.type == PIPELINE_START:
|
|
119
|
+
return replace(
|
|
120
|
+
state,
|
|
121
|
+
status="running",
|
|
122
|
+
started_at=action.payload or time.monotonic(),
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
if action.type == PHASE_START:
|
|
126
|
+
phase_name = action.payload
|
|
127
|
+
now = time.monotonic()
|
|
128
|
+
new_phases = tuple(
|
|
129
|
+
replace(p, status="running", started_at=now) if p.name == phase_name else p
|
|
130
|
+
for p in state.phases
|
|
131
|
+
)
|
|
132
|
+
return replace(state, phases=new_phases, current_phase=phase_name)
|
|
133
|
+
|
|
134
|
+
if action.type == PHASE_COMPLETE:
|
|
135
|
+
phase_name = (
|
|
136
|
+
action.payload.get("name", "")
|
|
137
|
+
if isinstance(action.payload, dict)
|
|
138
|
+
else action.payload
|
|
139
|
+
)
|
|
140
|
+
now = time.monotonic()
|
|
141
|
+
new_phases = []
|
|
142
|
+
completed_weight = 0
|
|
143
|
+
for p in state.phases:
|
|
144
|
+
if p.name == phase_name:
|
|
145
|
+
new_phases.append(
|
|
146
|
+
replace(p, status="completed", elapsed=now - p.started_at)
|
|
147
|
+
)
|
|
148
|
+
else:
|
|
149
|
+
new_phases.append(p)
|
|
150
|
+
if new_phases[-1].status == "completed":
|
|
151
|
+
completed_weight += weight_map.get(p.name, 1)
|
|
152
|
+
|
|
153
|
+
progress = completed_weight / total_weight if total_weight > 0 else 1.0
|
|
154
|
+
return replace(
|
|
155
|
+
state,
|
|
156
|
+
phases=tuple(new_phases),
|
|
157
|
+
progress=progress,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
if action.type == PHASE_FAILED:
|
|
161
|
+
payload = (
|
|
162
|
+
action.payload if isinstance(action.payload, dict) else {"name": action.payload}
|
|
163
|
+
)
|
|
164
|
+
phase_name = payload.get("name", "")
|
|
165
|
+
error = payload.get("error", "")
|
|
166
|
+
now = time.monotonic()
|
|
167
|
+
new_phases = tuple(
|
|
168
|
+
replace(p, status="failed", error=error, elapsed=now - p.started_at)
|
|
169
|
+
if p.name == phase_name
|
|
170
|
+
else p
|
|
171
|
+
for p in state.phases
|
|
172
|
+
)
|
|
173
|
+
return replace(
|
|
174
|
+
state,
|
|
175
|
+
phases=new_phases,
|
|
176
|
+
status="failed",
|
|
177
|
+
current_phase=phase_name,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
if action.type == PIPELINE_COMPLETE:
|
|
181
|
+
now = time.monotonic()
|
|
182
|
+
return replace(
|
|
183
|
+
state,
|
|
184
|
+
status="completed",
|
|
185
|
+
progress=1.0,
|
|
186
|
+
elapsed=now - state.started_at if state.started_at else 0.0,
|
|
187
|
+
current_phase="",
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
return state
|
|
191
|
+
|
|
192
|
+
return reducer
|
|
193
|
+
|
|
194
|
+
def build_saga(self) -> Callable:
|
|
195
|
+
"""Generate a saga that executes all phases in dependency order."""
|
|
196
|
+
phases = list(self.phases)
|
|
197
|
+
dep_graph = {p.name: set(p.depends_on) for p in phases}
|
|
198
|
+
phase_map = {p.name: p for p in phases}
|
|
199
|
+
|
|
200
|
+
def saga():
|
|
201
|
+
yield Put(Action(PIPELINE_START, time.monotonic()))
|
|
202
|
+
|
|
203
|
+
executed: set[str] = set()
|
|
204
|
+
remaining = set(dep_graph.keys())
|
|
205
|
+
|
|
206
|
+
while remaining:
|
|
207
|
+
# Find phases whose dependencies are all satisfied
|
|
208
|
+
ready = [name for name in remaining if dep_graph[name].issubset(executed)]
|
|
209
|
+
|
|
210
|
+
if not ready:
|
|
211
|
+
# Circular dependency or impossible state
|
|
212
|
+
yield Put(
|
|
213
|
+
Action(
|
|
214
|
+
PHASE_FAILED,
|
|
215
|
+
{"name": next(iter(remaining)), "error": "Unresolvable dependencies"},
|
|
216
|
+
)
|
|
217
|
+
)
|
|
218
|
+
return
|
|
219
|
+
|
|
220
|
+
# Separate parallel and sequential phases
|
|
221
|
+
parallel_ready = [n for n in ready if phase_map[n].parallel]
|
|
222
|
+
sequential_ready = [n for n in ready if not phase_map[n].parallel]
|
|
223
|
+
|
|
224
|
+
# Run parallel phases concurrently via Fork
|
|
225
|
+
if parallel_ready:
|
|
226
|
+
for name in parallel_ready:
|
|
227
|
+
yield Fork(_make_phase_saga(name, phase_map[name].handler))
|
|
228
|
+
# Mark them as executed (Fork runs concurrently)
|
|
229
|
+
for name in parallel_ready:
|
|
230
|
+
remaining.discard(name)
|
|
231
|
+
executed.add(name)
|
|
232
|
+
|
|
233
|
+
# Run sequential phases one at a time
|
|
234
|
+
for name in sequential_ready:
|
|
235
|
+
yield Put(Action(PHASE_START, name))
|
|
236
|
+
try:
|
|
237
|
+
result = yield Call(phase_map[name].handler)
|
|
238
|
+
yield Put(Action(PHASE_COMPLETE, {"name": name, "result": result}))
|
|
239
|
+
except Exception as e:
|
|
240
|
+
yield Put(Action(PHASE_FAILED, {"name": name, "error": str(e)}))
|
|
241
|
+
return
|
|
242
|
+
remaining.discard(name)
|
|
243
|
+
executed.add(name)
|
|
244
|
+
|
|
245
|
+
yield Put(Action(PIPELINE_COMPLETE))
|
|
246
|
+
|
|
247
|
+
return saga
|
|
248
|
+
|
|
249
|
+
def execution_order(self) -> list[str]:
|
|
250
|
+
"""Return the topological execution order of phases."""
|
|
251
|
+
dep_graph = {p.name: set(p.depends_on) for p in self.phases}
|
|
252
|
+
order: list[str] = []
|
|
253
|
+
remaining = set(dep_graph.keys())
|
|
254
|
+
|
|
255
|
+
while remaining:
|
|
256
|
+
ready = [n for n in remaining if dep_graph[n].issubset(set(order))]
|
|
257
|
+
if not ready:
|
|
258
|
+
break
|
|
259
|
+
order.extend(sorted(ready))
|
|
260
|
+
remaining -= set(ready)
|
|
261
|
+
|
|
262
|
+
return order
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _make_phase_saga(name: str, handler: Callable) -> Callable:
|
|
266
|
+
"""Create a saga for a single phase (used by Fork for parallel phases)."""
|
|
267
|
+
|
|
268
|
+
def phase_saga():
|
|
269
|
+
yield Put(Action(PHASE_START, name))
|
|
270
|
+
try:
|
|
271
|
+
result = yield Call(handler)
|
|
272
|
+
yield Put(Action(PHASE_COMPLETE, {"name": name, "result": result}))
|
|
273
|
+
except Exception as e:
|
|
274
|
+
yield Put(Action(PHASE_FAILED, {"name": name, "error": str(e)}))
|
|
275
|
+
|
|
276
|
+
return phase_saga
|
milo/plugins.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Plugin system with hook registry and Store middleware integration."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from milo._errors import ErrorCode, PluginError
|
|
10
|
+
from milo._types import Action
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class HookRegistry:
|
|
14
|
+
"""Registry of named hooks that plugins can subscribe to.
|
|
15
|
+
|
|
16
|
+
Usage::
|
|
17
|
+
|
|
18
|
+
hooks = HookRegistry()
|
|
19
|
+
hooks.define("before_build")
|
|
20
|
+
hooks.define("after_phase", required_args=("phase_name",))
|
|
21
|
+
|
|
22
|
+
@hooks.on("before_build")
|
|
23
|
+
def my_plugin(config):
|
|
24
|
+
print("Building with", config)
|
|
25
|
+
|
|
26
|
+
# Invoke all listeners
|
|
27
|
+
hooks.invoke("before_build", config=my_config)
|
|
28
|
+
|
|
29
|
+
# Freeze after setup (optional but recommended)
|
|
30
|
+
hooks.freeze()
|
|
31
|
+
|
|
32
|
+
# Use as Store middleware
|
|
33
|
+
store = Store(reducer, state, middleware=(hooks.as_middleware(),))
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self) -> None:
|
|
37
|
+
self._hooks: dict[str, list[Callable]] = {}
|
|
38
|
+
self._action_map: dict[str, str] = {} # action_type -> hook_name
|
|
39
|
+
self._frozen = False
|
|
40
|
+
self._lock = threading.Lock()
|
|
41
|
+
|
|
42
|
+
def define(
|
|
43
|
+
self,
|
|
44
|
+
name: str,
|
|
45
|
+
*,
|
|
46
|
+
action_type: str = "",
|
|
47
|
+
description: str = "",
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Define a named hook point.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
name: Hook name (e.g., "before_build", "after_phase").
|
|
53
|
+
action_type: If set, this hook fires automatically when
|
|
54
|
+
the Store dispatches an action of this type.
|
|
55
|
+
description: Human-readable description for docs/introspection.
|
|
56
|
+
"""
|
|
57
|
+
if self._frozen:
|
|
58
|
+
raise PluginError(
|
|
59
|
+
ErrorCode.PLG_HOOK,
|
|
60
|
+
f"Cannot define hook '{name}' — registry is frozen",
|
|
61
|
+
)
|
|
62
|
+
with self._lock:
|
|
63
|
+
if name not in self._hooks:
|
|
64
|
+
self._hooks[name] = []
|
|
65
|
+
if action_type:
|
|
66
|
+
self._action_map[action_type] = name
|
|
67
|
+
|
|
68
|
+
def on(self, hook_name: str) -> Callable:
|
|
69
|
+
"""Decorator to register a listener on a hook.
|
|
70
|
+
|
|
71
|
+
Usage::
|
|
72
|
+
|
|
73
|
+
@hooks.on("before_build")
|
|
74
|
+
def my_listener(config): ...
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def decorator(fn: Callable) -> Callable:
|
|
78
|
+
self.register(hook_name, fn)
|
|
79
|
+
return fn
|
|
80
|
+
|
|
81
|
+
return decorator
|
|
82
|
+
|
|
83
|
+
def register(self, hook_name: str, fn: Callable) -> None:
|
|
84
|
+
"""Register a listener function on a hook."""
|
|
85
|
+
if self._frozen:
|
|
86
|
+
raise PluginError(
|
|
87
|
+
ErrorCode.PLG_HOOK,
|
|
88
|
+
f"Cannot register on hook '{hook_name}' — registry is frozen",
|
|
89
|
+
)
|
|
90
|
+
with self._lock:
|
|
91
|
+
if hook_name not in self._hooks:
|
|
92
|
+
raise PluginError(
|
|
93
|
+
ErrorCode.PLG_HOOK,
|
|
94
|
+
f"Unknown hook '{hook_name}'",
|
|
95
|
+
suggestion=f"Define it first with hooks.define('{hook_name}')",
|
|
96
|
+
)
|
|
97
|
+
self._hooks[hook_name].append(fn)
|
|
98
|
+
|
|
99
|
+
def invoke(self, hook_name: str, **kwargs: Any) -> list[Any]:
|
|
100
|
+
"""Invoke all listeners registered on a hook.
|
|
101
|
+
|
|
102
|
+
Returns a list of return values from each listener.
|
|
103
|
+
Listeners are called in registration order.
|
|
104
|
+
"""
|
|
105
|
+
with self._lock:
|
|
106
|
+
listeners = list(self._hooks.get(hook_name, []))
|
|
107
|
+
|
|
108
|
+
results = []
|
|
109
|
+
for fn in listeners:
|
|
110
|
+
try:
|
|
111
|
+
results.append(fn(**kwargs))
|
|
112
|
+
except Exception as e:
|
|
113
|
+
raise PluginError(
|
|
114
|
+
ErrorCode.PLG_HOOK,
|
|
115
|
+
f"Hook '{hook_name}' listener {getattr(fn, '__name__', repr(fn))!r} raised: {e}",
|
|
116
|
+
) from e
|
|
117
|
+
return results
|
|
118
|
+
|
|
119
|
+
def freeze(self) -> None:
|
|
120
|
+
"""Freeze the registry — no more defines or registrations."""
|
|
121
|
+
self._frozen = True
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def frozen(self) -> bool:
|
|
125
|
+
return self._frozen
|
|
126
|
+
|
|
127
|
+
def hook_names(self) -> tuple[str, ...]:
|
|
128
|
+
"""Return all defined hook names."""
|
|
129
|
+
return tuple(self._hooks.keys())
|
|
130
|
+
|
|
131
|
+
def listeners(self, hook_name: str) -> tuple[Callable, ...]:
|
|
132
|
+
"""Return all listeners for a hook."""
|
|
133
|
+
return tuple(self._hooks.get(hook_name, []))
|
|
134
|
+
|
|
135
|
+
def as_middleware(self) -> Callable:
|
|
136
|
+
"""Return a Store middleware that fires hooks on matching actions.
|
|
137
|
+
|
|
138
|
+
The middleware intercepts actions whose type matches an
|
|
139
|
+
``action_type`` registered via ``define()``, invokes the
|
|
140
|
+
corresponding hook, and then passes the action through.
|
|
141
|
+
|
|
142
|
+
Usage::
|
|
143
|
+
|
|
144
|
+
store = Store(reducer, state, middleware=(hooks.as_middleware(),))
|
|
145
|
+
"""
|
|
146
|
+
action_map = self._action_map
|
|
147
|
+
registry = self
|
|
148
|
+
|
|
149
|
+
def middleware(store_api: Any) -> Callable:
|
|
150
|
+
def wrapper(next_dispatch: Callable) -> Callable:
|
|
151
|
+
def dispatch(action: Action) -> Any:
|
|
152
|
+
# Fire hook before reducer processes the action
|
|
153
|
+
hook_name = action_map.get(action.type)
|
|
154
|
+
if hook_name:
|
|
155
|
+
registry.invoke(
|
|
156
|
+
hook_name,
|
|
157
|
+
action=action,
|
|
158
|
+
get_state=store_api.get_state
|
|
159
|
+
if hasattr(store_api, "get_state")
|
|
160
|
+
else lambda: None,
|
|
161
|
+
)
|
|
162
|
+
return next_dispatch(action)
|
|
163
|
+
|
|
164
|
+
return dispatch
|
|
165
|
+
|
|
166
|
+
return wrapper
|
|
167
|
+
|
|
168
|
+
return middleware
|
milo/py.typed
ADDED
|
File without changes
|
milo/registry.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""Milo CLI registry — tracks installed CLIs for gateway discovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import time
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
_REGISTRY_DIR = Path.home() / ".milo"
|
|
15
|
+
_REGISTRY_FILE = _REGISTRY_DIR / "registry.json"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class HealthResult:
|
|
20
|
+
"""Result of a CLI health check."""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
reachable: bool
|
|
24
|
+
latency_ms: float
|
|
25
|
+
error: str = ""
|
|
26
|
+
stale: bool = False
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _load() -> dict[str, Any]:
|
|
30
|
+
"""Load the registry file."""
|
|
31
|
+
if not _REGISTRY_FILE.exists():
|
|
32
|
+
return {"version": 1, "clis": {}}
|
|
33
|
+
try:
|
|
34
|
+
return json.loads(_REGISTRY_FILE.read_text())
|
|
35
|
+
except json.JSONDecodeError:
|
|
36
|
+
return {"version": 1, "clis": {}}
|
|
37
|
+
except OSError:
|
|
38
|
+
return {"version": 1, "clis": {}}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _save(data: dict[str, Any]) -> None:
|
|
42
|
+
"""Save the registry file."""
|
|
43
|
+
_REGISTRY_DIR.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
_REGISTRY_FILE.write_text(json.dumps(data, indent=2) + "\n")
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def install(
|
|
48
|
+
name: str,
|
|
49
|
+
command: list[str],
|
|
50
|
+
*,
|
|
51
|
+
description: str = "",
|
|
52
|
+
version: str = "",
|
|
53
|
+
project_root: str = "",
|
|
54
|
+
) -> None:
|
|
55
|
+
"""Register a CLI in the milo registry.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
name: CLI name (used as namespace prefix in the gateway).
|
|
59
|
+
command: Shell command to start the CLI with --mcp.
|
|
60
|
+
description: Human-readable description.
|
|
61
|
+
version: CLI version string.
|
|
62
|
+
project_root: Absolute path to the project root.
|
|
63
|
+
"""
|
|
64
|
+
data = _load()
|
|
65
|
+
entry: dict[str, Any] = {
|
|
66
|
+
"command": command,
|
|
67
|
+
"description": description,
|
|
68
|
+
"version": version,
|
|
69
|
+
}
|
|
70
|
+
if project_root:
|
|
71
|
+
entry["project_root"] = project_root
|
|
72
|
+
entry["fingerprint"] = fingerprint(command, project_root)
|
|
73
|
+
entry["installed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
74
|
+
|
|
75
|
+
data["clis"][name] = entry
|
|
76
|
+
_save(data)
|
|
77
|
+
sys.stderr.write(f"Registered {name!r} in {_REGISTRY_FILE}\n")
|
|
78
|
+
sys.stderr.write(f" Command: {' '.join(command)}\n")
|
|
79
|
+
if description:
|
|
80
|
+
sys.stderr.write(f" Description: {description}\n")
|
|
81
|
+
sys.stderr.write("\n")
|
|
82
|
+
sys.stderr.write("Tools are available via the milo gateway:\n")
|
|
83
|
+
sys.stderr.write(" uv run python -m milo.gateway --mcp\n")
|
|
84
|
+
sys.stderr.flush()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def uninstall(name: str) -> bool:
|
|
88
|
+
"""Remove a CLI from the milo registry. Returns True if it was found."""
|
|
89
|
+
data = _load()
|
|
90
|
+
if name not in data.get("clis", {}):
|
|
91
|
+
sys.stderr.write(f"{name!r} not found in registry\n")
|
|
92
|
+
return False
|
|
93
|
+
del data["clis"][name]
|
|
94
|
+
_save(data)
|
|
95
|
+
sys.stderr.write(f"Removed {name!r} from {_REGISTRY_FILE}\n")
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def list_clis() -> dict[str, dict[str, Any]]:
|
|
100
|
+
"""Return all registered CLIs."""
|
|
101
|
+
data = _load()
|
|
102
|
+
return data.get("clis", {})
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def registry_path() -> Path:
|
|
106
|
+
"""Return the registry file path."""
|
|
107
|
+
return _REGISTRY_FILE
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def fingerprint(command: list[str], project_root: str) -> str:
|
|
111
|
+
"""Compute a SHA-256 fingerprint for a CLI entry."""
|
|
112
|
+
content = json.dumps({"command": command, "project_root": project_root}, sort_keys=True)
|
|
113
|
+
return hashlib.sha256(content.encode()).hexdigest()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def health_check(name: str) -> HealthResult:
|
|
117
|
+
"""Ping a registered CLI with initialize and measure latency."""
|
|
118
|
+
data = _load()
|
|
119
|
+
clis = data.get("clis", {})
|
|
120
|
+
if name not in clis:
|
|
121
|
+
return HealthResult(name=name, reachable=False, latency_ms=0.0, error="Not registered")
|
|
122
|
+
|
|
123
|
+
info = clis[name]
|
|
124
|
+
command = info.get("command", [])
|
|
125
|
+
if not command:
|
|
126
|
+
return HealthResult(name=name, reachable=False, latency_ms=0.0, error="No command")
|
|
127
|
+
|
|
128
|
+
start = time.monotonic()
|
|
129
|
+
try:
|
|
130
|
+
input_data = '{"jsonrpc":"2.0","id":1,"method":"initialize"}\n'
|
|
131
|
+
result = subprocess.run(
|
|
132
|
+
command,
|
|
133
|
+
input=input_data,
|
|
134
|
+
capture_output=True,
|
|
135
|
+
text=True,
|
|
136
|
+
timeout=10,
|
|
137
|
+
)
|
|
138
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
139
|
+
|
|
140
|
+
# Check for valid response
|
|
141
|
+
for line in result.stdout.strip().split("\n"):
|
|
142
|
+
if not line:
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
response = json.loads(line)
|
|
146
|
+
if response.get("id") == 1 and "result" in response:
|
|
147
|
+
# Check staleness
|
|
148
|
+
stored_fp = info.get("fingerprint", "")
|
|
149
|
+
project_root = info.get("project_root", "")
|
|
150
|
+
stale = False
|
|
151
|
+
if stored_fp and project_root:
|
|
152
|
+
current_fp = fingerprint(command, project_root)
|
|
153
|
+
stale = current_fp != stored_fp
|
|
154
|
+
return HealthResult(
|
|
155
|
+
name=name,
|
|
156
|
+
reachable=True,
|
|
157
|
+
latency_ms=round(elapsed, 2),
|
|
158
|
+
stale=stale,
|
|
159
|
+
)
|
|
160
|
+
except json.JSONDecodeError:
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
return HealthResult(
|
|
164
|
+
name=name,
|
|
165
|
+
reachable=False,
|
|
166
|
+
latency_ms=round(elapsed, 2),
|
|
167
|
+
error="No valid response",
|
|
168
|
+
)
|
|
169
|
+
except subprocess.TimeoutExpired:
|
|
170
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
171
|
+
return HealthResult(
|
|
172
|
+
name=name, reachable=False, latency_ms=round(elapsed, 2), error="Timeout"
|
|
173
|
+
)
|
|
174
|
+
except Exception as e:
|
|
175
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
176
|
+
return HealthResult(name=name, reachable=False, latency_ms=round(elapsed, 2), error=str(e))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def check_all() -> list[HealthResult]:
|
|
180
|
+
"""Run health checks on all registered CLIs."""
|
|
181
|
+
clis = list_clis()
|
|
182
|
+
return [health_check(name) for name in clis]
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def doctor() -> str:
|
|
186
|
+
"""Generate a diagnostic report for all registered CLIs."""
|
|
187
|
+
clis = list_clis()
|
|
188
|
+
if not clis:
|
|
189
|
+
return "No CLIs registered. Use --mcp-install on a milo CLI.\n"
|
|
190
|
+
|
|
191
|
+
lines: list[str] = []
|
|
192
|
+
lines.append("milo gateway — diagnostic report")
|
|
193
|
+
lines.append(f"Registry: {_REGISTRY_FILE}")
|
|
194
|
+
lines.append(f"CLIs: {len(clis)}")
|
|
195
|
+
lines.append("")
|
|
196
|
+
|
|
197
|
+
results = check_all()
|
|
198
|
+
for r in results:
|
|
199
|
+
status = "OK" if r.reachable else "FAIL"
|
|
200
|
+
stale_marker = " [STALE]" if r.stale else ""
|
|
201
|
+
lines.append(f" {r.name}: {status} ({r.latency_ms:.0f}ms){stale_marker}")
|
|
202
|
+
if r.error:
|
|
203
|
+
lines.append(f" Error: {r.error}")
|
|
204
|
+
|
|
205
|
+
lines.append("")
|
|
206
|
+
|
|
207
|
+
healthy = sum(1 for r in results if r.reachable)
|
|
208
|
+
lines.append(f"Summary: {healthy}/{len(results)} reachable")
|
|
209
|
+
stale = sum(1 for r in results if r.stale)
|
|
210
|
+
if stale:
|
|
211
|
+
lines.append(f" {stale} stale (fingerprint mismatch)")
|
|
212
|
+
lines.append("")
|
|
213
|
+
return "\n".join(lines)
|