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/gateway.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Milo MCP gateway — one MCP server that proxies to all registered CLIs.
|
|
2
|
+
|
|
3
|
+
Register this once with your AI host:
|
|
4
|
+
|
|
5
|
+
claude mcp add milo -- uv run python -m milo.gateway --mcp
|
|
6
|
+
|
|
7
|
+
Then every CLI registered via ``--mcp-install`` is discoverable.
|
|
8
|
+
Tools are namespaced: ``taskman.add``, ``ghub.repo.list``, etc.
|
|
9
|
+
|
|
10
|
+
Can also be run directly for debugging:
|
|
11
|
+
|
|
12
|
+
uv run python -m milo.gateway --mcp
|
|
13
|
+
uv run python -m milo.gateway --list
|
|
14
|
+
uv run python -m milo.gateway --doctor
|
|
15
|
+
uv run python -m milo.gateway --status
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from milo._child import ChildProcess
|
|
27
|
+
from milo.registry import list_clis
|
|
28
|
+
|
|
29
|
+
_MCP_VERSION = "2025-11-25"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main() -> None:
|
|
33
|
+
"""Entry point for ``python -m milo.gateway``."""
|
|
34
|
+
if "--list" in sys.argv:
|
|
35
|
+
_print_registry()
|
|
36
|
+
return
|
|
37
|
+
if "--doctor" in sys.argv:
|
|
38
|
+
from milo.registry import doctor
|
|
39
|
+
|
|
40
|
+
sys.stdout.write(doctor())
|
|
41
|
+
return
|
|
42
|
+
if "--status" in sys.argv:
|
|
43
|
+
_print_status()
|
|
44
|
+
return
|
|
45
|
+
if "--mcp" in sys.argv:
|
|
46
|
+
_run_gateway()
|
|
47
|
+
return
|
|
48
|
+
# Default: show help
|
|
49
|
+
sys.stderr.write("milo gateway — one MCP server for all your CLIs\n\n")
|
|
50
|
+
sys.stderr.write("Usage:\n")
|
|
51
|
+
sys.stderr.write(" python -m milo.gateway --mcp Run as MCP server\n")
|
|
52
|
+
sys.stderr.write(" python -m milo.gateway --list List registered CLIs\n")
|
|
53
|
+
sys.stderr.write(" python -m milo.gateway --doctor Health check all CLIs\n")
|
|
54
|
+
sys.stderr.write(" python -m milo.gateway --status Show stats and children\n")
|
|
55
|
+
sys.stderr.write("\nRegister CLIs with: myapp --mcp-install\n")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _print_registry() -> None:
|
|
59
|
+
"""Print all registered CLIs."""
|
|
60
|
+
clis = list_clis()
|
|
61
|
+
if not clis:
|
|
62
|
+
sys.stderr.write("No CLIs registered. Use --mcp-install on a milo CLI.\n")
|
|
63
|
+
return
|
|
64
|
+
for name, info in clis.items():
|
|
65
|
+
desc = info.get("description", "")
|
|
66
|
+
ver = info.get("version", "")
|
|
67
|
+
cmd = " ".join(info.get("command", []))
|
|
68
|
+
label = f"{name} {ver}".strip()
|
|
69
|
+
sys.stdout.write(f"{label}\n")
|
|
70
|
+
if desc:
|
|
71
|
+
sys.stdout.write(f" {desc}\n")
|
|
72
|
+
sys.stdout.write(f" {cmd}\n\n")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _print_status() -> None:
|
|
76
|
+
"""Print gateway status (placeholder for F7 observability)."""
|
|
77
|
+
clis = list_clis()
|
|
78
|
+
sys.stdout.write(f"Registered CLIs: {len(clis)}\n")
|
|
79
|
+
for name in clis:
|
|
80
|
+
sys.stdout.write(f" {name}\n")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _run_gateway() -> None:
|
|
84
|
+
"""Run the MCP gateway server with persistent child processes."""
|
|
85
|
+
clis = list_clis()
|
|
86
|
+
|
|
87
|
+
# Create persistent children for each CLI
|
|
88
|
+
children: dict[str, ChildProcess] = {}
|
|
89
|
+
for cli_name, info in clis.items():
|
|
90
|
+
command = info.get("command", [])
|
|
91
|
+
if command:
|
|
92
|
+
children[cli_name] = ChildProcess(cli_name, command)
|
|
93
|
+
|
|
94
|
+
# Discover tools from all registered CLIs
|
|
95
|
+
all_tools, tool_routing = _discover_tools(clis, children)
|
|
96
|
+
tool_names = [t["name"] for t in all_tools]
|
|
97
|
+
|
|
98
|
+
# Discover resources and prompts
|
|
99
|
+
all_resources, resource_routing = _discover_resources(clis, children)
|
|
100
|
+
all_prompts, prompt_routing = _discover_prompts(clis, children)
|
|
101
|
+
|
|
102
|
+
_stderr("milo gateway ready")
|
|
103
|
+
_stderr(f" Protocol: {_MCP_VERSION}")
|
|
104
|
+
_stderr(f" CLIs: {len(clis)} ({', '.join(clis.keys()) if clis else 'none'})")
|
|
105
|
+
_stderr(f" Tools: {len(all_tools)}")
|
|
106
|
+
_stderr(f" Resources: {len(all_resources)}")
|
|
107
|
+
_stderr(f" Prompts: {len(all_prompts)}")
|
|
108
|
+
if tool_names:
|
|
109
|
+
_stderr(f" Available: {', '.join(tool_names)}")
|
|
110
|
+
_stderr("")
|
|
111
|
+
_stderr("Press Ctrl+C to stop.")
|
|
112
|
+
_stderr("")
|
|
113
|
+
|
|
114
|
+
# Start idle reaper thread
|
|
115
|
+
reaper = threading.Thread(target=_idle_reaper, args=(children,), daemon=True)
|
|
116
|
+
reaper.start()
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
for line in sys.stdin:
|
|
120
|
+
line = line.strip()
|
|
121
|
+
if not line:
|
|
122
|
+
continue
|
|
123
|
+
try:
|
|
124
|
+
request = json.loads(line)
|
|
125
|
+
except json.JSONDecodeError:
|
|
126
|
+
_write_error(None, -32700, "Parse error")
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
req_id = request.get("id")
|
|
130
|
+
method = request.get("method", "")
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
result = _handle_method(
|
|
134
|
+
clis,
|
|
135
|
+
all_tools,
|
|
136
|
+
tool_routing,
|
|
137
|
+
all_resources,
|
|
138
|
+
resource_routing,
|
|
139
|
+
all_prompts,
|
|
140
|
+
prompt_routing,
|
|
141
|
+
children,
|
|
142
|
+
method,
|
|
143
|
+
request.get("params", {}),
|
|
144
|
+
)
|
|
145
|
+
if result is not None:
|
|
146
|
+
_write_result(req_id, result)
|
|
147
|
+
except Exception as e:
|
|
148
|
+
_write_error(req_id, -32603, str(e))
|
|
149
|
+
finally:
|
|
150
|
+
# Clean up children on exit
|
|
151
|
+
for child in children.values():
|
|
152
|
+
child.kill()
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _idle_reaper(children: dict[str, ChildProcess]) -> None:
|
|
156
|
+
"""Periodically check and kill idle children."""
|
|
157
|
+
while True:
|
|
158
|
+
time.sleep(60)
|
|
159
|
+
for child in list(children.values()):
|
|
160
|
+
if child.is_idle():
|
|
161
|
+
_stderr(f" Reaping idle child: {child.name}")
|
|
162
|
+
child.kill()
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _discover_tools(
|
|
166
|
+
clis: dict[str, dict[str, Any]],
|
|
167
|
+
children: dict[str, ChildProcess],
|
|
168
|
+
) -> tuple[list[dict[str, Any]], dict[str, tuple[str, str]]]:
|
|
169
|
+
"""Discover tools from all registered CLIs via persistent children."""
|
|
170
|
+
all_tools: list[dict[str, Any]] = []
|
|
171
|
+
routing: dict[str, tuple[str, str]] = {}
|
|
172
|
+
|
|
173
|
+
for cli_name in clis:
|
|
174
|
+
child = children.get(cli_name)
|
|
175
|
+
if not child:
|
|
176
|
+
continue
|
|
177
|
+
|
|
178
|
+
try:
|
|
179
|
+
tools = child.fetch_tools()
|
|
180
|
+
except Exception as e:
|
|
181
|
+
_stderr(f" Warning: failed to discover {cli_name}: {e}")
|
|
182
|
+
continue
|
|
183
|
+
|
|
184
|
+
for tool in tools:
|
|
185
|
+
original_name = tool["name"]
|
|
186
|
+
namespaced = f"{cli_name}.{original_name}"
|
|
187
|
+
tool["name"] = namespaced
|
|
188
|
+
if "title" not in tool:
|
|
189
|
+
tool["title"] = f"{cli_name}: {tool.get('description', original_name)}"
|
|
190
|
+
all_tools.append(tool)
|
|
191
|
+
routing[namespaced] = (cli_name, original_name)
|
|
192
|
+
|
|
193
|
+
return all_tools, routing
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _discover_resources(
|
|
197
|
+
clis: dict[str, dict[str, Any]],
|
|
198
|
+
children: dict[str, ChildProcess],
|
|
199
|
+
) -> tuple[list[dict[str, Any]], dict[str, tuple[str, str]]]:
|
|
200
|
+
"""Discover resources from all registered CLIs."""
|
|
201
|
+
all_resources: list[dict[str, Any]] = []
|
|
202
|
+
routing: dict[str, tuple[str, str]] = {}
|
|
203
|
+
|
|
204
|
+
for cli_name in clis:
|
|
205
|
+
child = children.get(cli_name)
|
|
206
|
+
if not child:
|
|
207
|
+
continue
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
result = child.send_call("resources/list", {})
|
|
211
|
+
resources = result.get("resources", [])
|
|
212
|
+
except Exception:
|
|
213
|
+
continue
|
|
214
|
+
|
|
215
|
+
for resource in resources:
|
|
216
|
+
original_uri = resource["uri"]
|
|
217
|
+
namespaced_uri = f"{cli_name}/{original_uri}"
|
|
218
|
+
resource["uri"] = namespaced_uri
|
|
219
|
+
all_resources.append(resource)
|
|
220
|
+
routing[namespaced_uri] = (cli_name, original_uri)
|
|
221
|
+
|
|
222
|
+
return all_resources, routing
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _discover_prompts(
|
|
226
|
+
clis: dict[str, dict[str, Any]],
|
|
227
|
+
children: dict[str, ChildProcess],
|
|
228
|
+
) -> tuple[list[dict[str, Any]], dict[str, tuple[str, str]]]:
|
|
229
|
+
"""Discover prompts from all registered CLIs."""
|
|
230
|
+
all_prompts: list[dict[str, Any]] = []
|
|
231
|
+
routing: dict[str, tuple[str, str]] = {}
|
|
232
|
+
|
|
233
|
+
for cli_name in clis:
|
|
234
|
+
child = children.get(cli_name)
|
|
235
|
+
if not child:
|
|
236
|
+
continue
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
result = child.send_call("prompts/list", {})
|
|
240
|
+
prompts = result.get("prompts", [])
|
|
241
|
+
except Exception:
|
|
242
|
+
continue
|
|
243
|
+
|
|
244
|
+
for prompt in prompts:
|
|
245
|
+
original_name = prompt["name"]
|
|
246
|
+
namespaced = f"{cli_name}.{original_name}"
|
|
247
|
+
prompt["name"] = namespaced
|
|
248
|
+
all_prompts.append(prompt)
|
|
249
|
+
routing[namespaced] = (cli_name, original_name)
|
|
250
|
+
|
|
251
|
+
return all_prompts, routing
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _handle_method(
|
|
255
|
+
clis: dict[str, dict[str, Any]],
|
|
256
|
+
all_tools: list[dict[str, Any]],
|
|
257
|
+
tool_routing: dict[str, tuple[str, str]],
|
|
258
|
+
all_resources: list[dict[str, Any]],
|
|
259
|
+
resource_routing: dict[str, tuple[str, str]],
|
|
260
|
+
all_prompts: list[dict[str, Any]],
|
|
261
|
+
prompt_routing: dict[str, tuple[str, str]],
|
|
262
|
+
children: dict[str, ChildProcess],
|
|
263
|
+
method: str,
|
|
264
|
+
params: dict[str, Any],
|
|
265
|
+
) -> dict[str, Any] | None:
|
|
266
|
+
"""Dispatch an MCP method."""
|
|
267
|
+
match method:
|
|
268
|
+
case "initialize":
|
|
269
|
+
cli_names = list(clis.keys())
|
|
270
|
+
return {
|
|
271
|
+
"protocolVersion": _MCP_VERSION,
|
|
272
|
+
"capabilities": {"tools": {}, "resources": {}, "prompts": {}},
|
|
273
|
+
"serverInfo": {
|
|
274
|
+
"name": "milo-gateway",
|
|
275
|
+
"version": "0.1.0",
|
|
276
|
+
"title": "Milo Gateway",
|
|
277
|
+
},
|
|
278
|
+
"instructions": (
|
|
279
|
+
f"Gateway to {len(clis)} milo CLIs: {', '.join(cli_names)}. "
|
|
280
|
+
"Tools are namespaced as cli_name.command_name."
|
|
281
|
+
),
|
|
282
|
+
}
|
|
283
|
+
case "notifications/initialized":
|
|
284
|
+
return None
|
|
285
|
+
case "tools/list":
|
|
286
|
+
return {"tools": all_tools}
|
|
287
|
+
case "tools/call":
|
|
288
|
+
return _proxy_call(children, tool_routing, params)
|
|
289
|
+
case "resources/list":
|
|
290
|
+
return {"resources": all_resources}
|
|
291
|
+
case "resources/read":
|
|
292
|
+
return _proxy_resource(children, resource_routing, params)
|
|
293
|
+
case "prompts/list":
|
|
294
|
+
return {"prompts": all_prompts}
|
|
295
|
+
case "prompts/get":
|
|
296
|
+
return _proxy_prompt(children, prompt_routing, params)
|
|
297
|
+
case _:
|
|
298
|
+
raise ValueError(f"Unknown method: {method!r}")
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _proxy_call(
|
|
302
|
+
children: dict[str, ChildProcess],
|
|
303
|
+
tool_routing: dict[str, tuple[str, str]],
|
|
304
|
+
params: dict[str, Any],
|
|
305
|
+
) -> dict[str, Any]:
|
|
306
|
+
"""Proxy a tools/call to the appropriate child process."""
|
|
307
|
+
tool_name = params.get("name", "")
|
|
308
|
+
arguments = params.get("arguments", {})
|
|
309
|
+
|
|
310
|
+
if tool_name not in tool_routing:
|
|
311
|
+
return {
|
|
312
|
+
"content": [{"type": "text", "text": f"Unknown tool: {tool_name!r}"}],
|
|
313
|
+
"isError": True,
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
cli_name, original_name = tool_routing[tool_name]
|
|
317
|
+
child = children.get(cli_name)
|
|
318
|
+
if not child:
|
|
319
|
+
return {
|
|
320
|
+
"content": [{"type": "text", "text": f"CLI {cli_name!r} not available"}],
|
|
321
|
+
"isError": True,
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
result = child.send_call("tools/call", {"name": original_name, "arguments": arguments})
|
|
325
|
+
if "error" in result:
|
|
326
|
+
return {
|
|
327
|
+
"content": [{"type": "text", "text": result["error"].get("message", "Unknown error")}],
|
|
328
|
+
"isError": True,
|
|
329
|
+
}
|
|
330
|
+
return result
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _proxy_resource(
|
|
334
|
+
children: dict[str, ChildProcess],
|
|
335
|
+
resource_routing: dict[str, tuple[str, str]],
|
|
336
|
+
params: dict[str, Any],
|
|
337
|
+
) -> dict[str, Any]:
|
|
338
|
+
"""Proxy a resources/read to the appropriate child process."""
|
|
339
|
+
uri = params.get("uri", "")
|
|
340
|
+
if uri not in resource_routing:
|
|
341
|
+
return {"contents": []}
|
|
342
|
+
|
|
343
|
+
cli_name, original_uri = resource_routing[uri]
|
|
344
|
+
child = children.get(cli_name)
|
|
345
|
+
if not child:
|
|
346
|
+
return {"contents": []}
|
|
347
|
+
|
|
348
|
+
return child.send_call("resources/read", {"uri": original_uri})
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _proxy_prompt(
|
|
352
|
+
children: dict[str, ChildProcess],
|
|
353
|
+
prompt_routing: dict[str, tuple[str, str]],
|
|
354
|
+
params: dict[str, Any],
|
|
355
|
+
) -> dict[str, Any]:
|
|
356
|
+
"""Proxy a prompts/get to the appropriate child process."""
|
|
357
|
+
name = params.get("name", "")
|
|
358
|
+
if name not in prompt_routing:
|
|
359
|
+
return {"messages": []}
|
|
360
|
+
|
|
361
|
+
cli_name, original_name = prompt_routing[name]
|
|
362
|
+
child = children.get(cli_name)
|
|
363
|
+
if not child:
|
|
364
|
+
return {"messages": []}
|
|
365
|
+
|
|
366
|
+
return child.send_call(
|
|
367
|
+
"prompts/get", {"name": original_name, "arguments": params.get("arguments", {})}
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _write_result(req_id: Any, result: dict[str, Any]) -> None:
|
|
372
|
+
response = {"jsonrpc": "2.0", "id": req_id, "result": result}
|
|
373
|
+
sys.stdout.write(json.dumps(response) + "\n")
|
|
374
|
+
sys.stdout.flush()
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _write_error(req_id: Any, code: int, message: str) -> None:
|
|
378
|
+
response = {
|
|
379
|
+
"jsonrpc": "2.0",
|
|
380
|
+
"id": req_id,
|
|
381
|
+
"error": {"code": code, "message": message},
|
|
382
|
+
}
|
|
383
|
+
sys.stdout.write(json.dumps(response) + "\n")
|
|
384
|
+
sys.stdout.flush()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _stderr(message: str) -> None:
|
|
388
|
+
sys.stderr.write(message + "\n")
|
|
389
|
+
sys.stderr.flush()
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
if __name__ == "__main__":
|
|
393
|
+
main()
|
milo/groups.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Nestable command groups for hierarchical CLI structures."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from milo.schema import function_to_schema
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True, slots=True)
|
|
13
|
+
class GroupDef:
|
|
14
|
+
"""Frozen snapshot of a command group."""
|
|
15
|
+
|
|
16
|
+
name: str
|
|
17
|
+
description: str
|
|
18
|
+
commands: dict[str, Any] = field(default_factory=dict) # str -> CommandDef
|
|
19
|
+
groups: dict[str, GroupDef] = field(default_factory=dict)
|
|
20
|
+
aliases: tuple[str, ...] = ()
|
|
21
|
+
hidden: bool = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Group:
|
|
25
|
+
"""A nestable command namespace.
|
|
26
|
+
|
|
27
|
+
Usage::
|
|
28
|
+
|
|
29
|
+
site = cli.group("site", description="Site operations")
|
|
30
|
+
|
|
31
|
+
@site.command("build", description="Build the site")
|
|
32
|
+
def build(output: str = "_site") -> str: ...
|
|
33
|
+
|
|
34
|
+
config = site.group("config", description="Config management")
|
|
35
|
+
|
|
36
|
+
@config.command("show", description="Show merged config")
|
|
37
|
+
def show() -> dict: ...
|
|
38
|
+
|
|
39
|
+
CLI::
|
|
40
|
+
|
|
41
|
+
myapp site build --output _site
|
|
42
|
+
myapp site config show
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
name: str,
|
|
48
|
+
*,
|
|
49
|
+
description: str = "",
|
|
50
|
+
aliases: tuple[str, ...] | list[str] = (),
|
|
51
|
+
hidden: bool = False,
|
|
52
|
+
) -> None:
|
|
53
|
+
self.name = name
|
|
54
|
+
self.description = description
|
|
55
|
+
self.aliases = tuple(aliases)
|
|
56
|
+
self.hidden = hidden
|
|
57
|
+
self._commands: dict[str, Any] = {} # str -> CommandDef
|
|
58
|
+
self._alias_map: dict[str, str] = {}
|
|
59
|
+
self._groups: dict[str, Group] = {}
|
|
60
|
+
self._group_alias_map: dict[str, str] = {}
|
|
61
|
+
|
|
62
|
+
def command(
|
|
63
|
+
self,
|
|
64
|
+
name: str,
|
|
65
|
+
*,
|
|
66
|
+
description: str = "",
|
|
67
|
+
aliases: tuple[str, ...] | list[str] = (),
|
|
68
|
+
tags: tuple[str, ...] | list[str] = (),
|
|
69
|
+
hidden: bool = False,
|
|
70
|
+
) -> Callable:
|
|
71
|
+
"""Register a function as a command within this group."""
|
|
72
|
+
from milo.commands import CommandDef
|
|
73
|
+
|
|
74
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
75
|
+
schema = function_to_schema(func)
|
|
76
|
+
desc = description or func.__doc__ or ""
|
|
77
|
+
if "\n" in desc:
|
|
78
|
+
desc = desc.strip().split("\n")[0].strip()
|
|
79
|
+
|
|
80
|
+
cmd = CommandDef(
|
|
81
|
+
name=name,
|
|
82
|
+
description=desc,
|
|
83
|
+
handler=func,
|
|
84
|
+
schema=schema,
|
|
85
|
+
aliases=tuple(aliases),
|
|
86
|
+
tags=tuple(tags),
|
|
87
|
+
hidden=hidden,
|
|
88
|
+
)
|
|
89
|
+
self._commands[name] = cmd
|
|
90
|
+
for alias in aliases:
|
|
91
|
+
self._alias_map[alias] = name
|
|
92
|
+
|
|
93
|
+
return func
|
|
94
|
+
|
|
95
|
+
return decorator
|
|
96
|
+
|
|
97
|
+
def lazy_command(
|
|
98
|
+
self,
|
|
99
|
+
name: str,
|
|
100
|
+
import_path: str,
|
|
101
|
+
*,
|
|
102
|
+
description: str = "",
|
|
103
|
+
schema: dict[str, Any] | None = None,
|
|
104
|
+
aliases: tuple[str, ...] | list[str] = (),
|
|
105
|
+
tags: tuple[str, ...] | list[str] = (),
|
|
106
|
+
hidden: bool = False,
|
|
107
|
+
) -> Any:
|
|
108
|
+
"""Register a lazy-loaded command within this group.
|
|
109
|
+
|
|
110
|
+
The handler module is not imported until the command is invoked.
|
|
111
|
+
"""
|
|
112
|
+
from milo.commands import LazyCommandDef
|
|
113
|
+
|
|
114
|
+
cmd = LazyCommandDef(
|
|
115
|
+
name=name,
|
|
116
|
+
import_path=import_path,
|
|
117
|
+
description=description,
|
|
118
|
+
schema=schema,
|
|
119
|
+
aliases=aliases,
|
|
120
|
+
tags=tags,
|
|
121
|
+
hidden=hidden,
|
|
122
|
+
)
|
|
123
|
+
self._commands[name] = cmd
|
|
124
|
+
for alias in aliases:
|
|
125
|
+
self._alias_map[alias] = name
|
|
126
|
+
return cmd
|
|
127
|
+
|
|
128
|
+
def group(
|
|
129
|
+
self,
|
|
130
|
+
name: str,
|
|
131
|
+
*,
|
|
132
|
+
description: str = "",
|
|
133
|
+
aliases: tuple[str, ...] | list[str] = (),
|
|
134
|
+
hidden: bool = False,
|
|
135
|
+
) -> Group:
|
|
136
|
+
"""Create and register a sub-group. Returns it for chaining."""
|
|
137
|
+
sub = Group(name, description=description, aliases=aliases, hidden=hidden)
|
|
138
|
+
self._groups[name] = sub
|
|
139
|
+
for alias in aliases:
|
|
140
|
+
self._group_alias_map[alias] = name
|
|
141
|
+
return sub
|
|
142
|
+
|
|
143
|
+
def add_group(self, group: Group) -> None:
|
|
144
|
+
"""Add an existing Group as a sub-group."""
|
|
145
|
+
self._groups[group.name] = group
|
|
146
|
+
for alias in group.aliases:
|
|
147
|
+
self._group_alias_map[alias] = group.name
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def commands(self) -> dict[str, Any]:
|
|
151
|
+
"""All registered commands in this group."""
|
|
152
|
+
return dict(self._commands)
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def groups(self) -> dict[str, Group]:
|
|
156
|
+
"""All registered sub-groups."""
|
|
157
|
+
return dict(self._groups)
|
|
158
|
+
|
|
159
|
+
def get_command(self, name: str) -> Any | None:
|
|
160
|
+
"""Look up a command by name or alias within this group."""
|
|
161
|
+
if name in self._commands:
|
|
162
|
+
return self._commands[name]
|
|
163
|
+
resolved = self._alias_map.get(name)
|
|
164
|
+
if resolved:
|
|
165
|
+
return self._commands.get(resolved)
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
def get_group(self, name: str) -> Group | None:
|
|
169
|
+
"""Look up a sub-group by name or alias."""
|
|
170
|
+
if name in self._groups:
|
|
171
|
+
return self._groups[name]
|
|
172
|
+
resolved = self._group_alias_map.get(name)
|
|
173
|
+
if resolved:
|
|
174
|
+
return self._groups.get(resolved)
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
def to_def(self) -> GroupDef:
|
|
178
|
+
"""Freeze into immutable GroupDef tree."""
|
|
179
|
+
return GroupDef(
|
|
180
|
+
name=self.name,
|
|
181
|
+
description=self.description,
|
|
182
|
+
commands=dict(self._commands),
|
|
183
|
+
groups={n: g.to_def() for n, g in self._groups.items()},
|
|
184
|
+
aliases=self.aliases,
|
|
185
|
+
hidden=self.hidden,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def walk_commands(self, prefix: str = "") -> list[tuple[str, Any]]:
|
|
189
|
+
"""Yield (dotted_path, CommandDef) for all commands in this tree."""
|
|
190
|
+
path_prefix = f"{prefix}{self.name}." if prefix else f"{self.name}."
|
|
191
|
+
result = [(f"{path_prefix}{cmd.name}", cmd) for cmd in self._commands.values()]
|
|
192
|
+
for group in self._groups.values():
|
|
193
|
+
result.extend(group.walk_commands(path_prefix))
|
|
194
|
+
return result
|
milo/help.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""HelpRenderer — argparse formatter_class for styled help output."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class HelpState:
|
|
12
|
+
"""State for help rendering."""
|
|
13
|
+
|
|
14
|
+
prog: str = ""
|
|
15
|
+
description: str = ""
|
|
16
|
+
epilog: str = ""
|
|
17
|
+
usage: str = ""
|
|
18
|
+
groups: tuple[dict[str, Any], ...] = ()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HelpRenderer(argparse.HelpFormatter):
|
|
22
|
+
"""Argparse formatter that renders through kida templates.
|
|
23
|
+
|
|
24
|
+
Usage::
|
|
25
|
+
|
|
26
|
+
parser = argparse.ArgumentParser(
|
|
27
|
+
formatter_class=HelpRenderer,
|
|
28
|
+
)
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
prog: str,
|
|
34
|
+
indent_increment: int = 2,
|
|
35
|
+
max_help_position: int = 24,
|
|
36
|
+
width: int | None = None,
|
|
37
|
+
) -> None:
|
|
38
|
+
super().__init__(prog, indent_increment, max_help_position, width)
|
|
39
|
+
self._prog = prog
|
|
40
|
+
|
|
41
|
+
def format_help(self) -> str:
|
|
42
|
+
"""Format help using kida template if available, else fall back to default."""
|
|
43
|
+
try:
|
|
44
|
+
return self._render_with_template()
|
|
45
|
+
except Exception:
|
|
46
|
+
return super().format_help()
|
|
47
|
+
|
|
48
|
+
def _render_with_template(self) -> str:
|
|
49
|
+
"""Render help through the kida help template."""
|
|
50
|
+
from milo.templates import get_env
|
|
51
|
+
|
|
52
|
+
env = get_env()
|
|
53
|
+
template = env.get_template("help.kida")
|
|
54
|
+
|
|
55
|
+
groups = []
|
|
56
|
+
for action_group in self._action_groups: # type: ignore[attr-defined]
|
|
57
|
+
actions = [
|
|
58
|
+
{
|
|
59
|
+
"option_strings": action.option_strings,
|
|
60
|
+
"dest": action.dest,
|
|
61
|
+
"help": action.help or "",
|
|
62
|
+
"default": action.default,
|
|
63
|
+
"required": getattr(action, "required", False),
|
|
64
|
+
"choices": action.choices,
|
|
65
|
+
"nargs": action.nargs,
|
|
66
|
+
"metavar": action.metavar,
|
|
67
|
+
}
|
|
68
|
+
for action in action_group._group_actions
|
|
69
|
+
]
|
|
70
|
+
if actions:
|
|
71
|
+
groups.append(
|
|
72
|
+
{
|
|
73
|
+
"title": action_group.title or "",
|
|
74
|
+
"actions": actions,
|
|
75
|
+
}
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
state = HelpState(
|
|
79
|
+
prog=self._prog,
|
|
80
|
+
description=self._root_section.heading or "",
|
|
81
|
+
groups=tuple(groups),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return template.render(state=state)
|